From eb6d3d71ed11645dfb495cf05e2238339cd0967e Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 28 Apr 2025 00:31:58 -0400 Subject: [PATCH 1/4] Customizable headers for the OpenAI-compatible provider --- src/api/providers/openai.ts | 28 ++--- src/core/config/ContextProxy.ts | 10 ++ src/core/config/ProviderSettingsManager.ts | 33 +++++ .../__tests__/ProviderSettingsManager.test.ts | 1 + src/core/webview/webviewMessageHandler.ts | 2 +- src/exports/roo-code.d.ts | 7 +- src/exports/types.ts | 7 +- src/schemas/index.ts | 6 +- .../src/components/settings/ApiOptions.tsx | 115 ++++++++++++++---- webview-ui/src/i18n/locales/ca/settings.json | 8 +- webview-ui/src/i18n/locales/de/settings.json | 8 +- webview-ui/src/i18n/locales/en/settings.json | 8 +- webview-ui/src/i18n/locales/es/settings.json | 8 +- webview-ui/src/i18n/locales/fr/settings.json | 8 +- webview-ui/src/i18n/locales/hi/settings.json | 8 +- webview-ui/src/i18n/locales/it/settings.json | 8 +- webview-ui/src/i18n/locales/ja/settings.json | 8 +- webview-ui/src/i18n/locales/ko/settings.json | 8 +- webview-ui/src/i18n/locales/pl/settings.json | 8 +- .../src/i18n/locales/pt-BR/settings.json | 8 +- webview-ui/src/i18n/locales/ru/settings.json | 8 +- webview-ui/src/i18n/locales/tr/settings.json | 8 +- webview-ui/src/i18n/locales/vi/settings.json | 8 +- .../src/i18n/locales/zh-CN/settings.json | 8 +- .../src/i18n/locales/zh-TW/settings.json | 8 +- 25 files changed, 279 insertions(+), 58 deletions(-) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 10cc5e40cd..64932b0392 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -35,12 +35,17 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const urlHost = this._getUrlHost(this.options.openAiBaseUrl) const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure + const headers = { + ...DEFAULT_HEADERS, + ...(this.options.openAiHeaders || {}), + } + if (isAzureAiInference) { // Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure this.client = new OpenAI({ baseURL, apiKey, - defaultHeaders: DEFAULT_HEADERS, + defaultHeaders: headers, defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" }, }) } else if (isAzureOpenAi) { @@ -50,19 +55,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl baseURL, apiKey, apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, - defaultHeaders: { - ...DEFAULT_HEADERS, - ...(this.options.openAiHostHeader ? { Host: this.options.openAiHostHeader } : {}), - }, + defaultHeaders: headers, }) } else { this.client = new OpenAI({ baseURL, apiKey, - defaultHeaders: { - ...DEFAULT_HEADERS, - ...(this.options.openAiHostHeader ? { Host: this.options.openAiHostHeader } : {}), - }, + defaultHeaders: headers, }) } } @@ -361,7 +360,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } -export async function getOpenAiModels(baseUrl?: string, apiKey?: string, hostHeader?: string) { +export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record) { try { if (!baseUrl) { return [] @@ -372,16 +371,15 @@ export async function getOpenAiModels(baseUrl?: string, apiKey?: string, hostHea } const config: Record = {} - const headers: Record = {} + const headers: Record = { + ...DEFAULT_HEADERS, + ...(openAiHeaders || {}), + } if (apiKey) { headers["Authorization"] = `Bearer ${apiKey}` } - if (hostHeader) { - headers["Host"] = hostHeader - } - if (Object.keys(headers).length > 0) { config["headers"] = headers } diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 6b017503d5..c2373ccad2 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -192,6 +192,16 @@ export class ContextProxy { // If a value is not present in the new configuration, then it is assumed // that the setting's value should be `undefined` and therefore we // need to remove it from the state cache if it exists. + + // Ensure openAiHeaders is always an object even when empty + // This is critical for proper serialization/deserialization through IPC + if (values.openAiHeaders !== undefined) { + // Check if it's empty or null + if (!values.openAiHeaders || Object.keys(values.openAiHeaders).length === 0) { + values.openAiHeaders = {} + } + } + await this.setValues({ ...PROVIDER_SETTINGS_KEYS.filter((key) => !isSecretStateKey(key)) .filter((key) => !!this.stateCache[key]) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 7df34e7844..ca53cba889 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -17,6 +17,7 @@ export const providerProfilesSchema = z.object({ .object({ rateLimitSecondsMigrated: z.boolean().optional(), diffSettingsMigrated: z.boolean().optional(), + openAiHeadersMigrated: z.boolean().optional(), }) .optional(), }) @@ -38,6 +39,7 @@ export class ProviderSettingsManager { migrations: { rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs diffSettingsMigrated: true, // Mark as migrated on fresh installs + openAiHeadersMigrated: true, // Mark as migrated on fresh installs }, } @@ -90,6 +92,7 @@ export class ProviderSettingsManager { providerProfiles.migrations = { rateLimitSecondsMigrated: false, diffSettingsMigrated: false, + openAiHeadersMigrated: false, } // Initialize with default values isDirty = true } @@ -106,6 +109,12 @@ export class ProviderSettingsManager { isDirty = true } + if (!providerProfiles.migrations.openAiHeadersMigrated) { + await this.migrateOpenAiHeaders(providerProfiles) + providerProfiles.migrations.openAiHeadersMigrated = true + isDirty = true + } + if (isDirty) { await this.store(providerProfiles) } @@ -175,6 +184,30 @@ export class ProviderSettingsManager { } } + private async migrateOpenAiHeaders(providerProfiles: ProviderProfiles) { + try { + for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { + // Use type assertion to access the deprecated property safely + const configAny = apiConfig as any + + // Check if openAiHostHeader exists but openAiHeaders doesn't + if ( + configAny.openAiHostHeader && + (!apiConfig.openAiHeaders || Object.keys(apiConfig.openAiHeaders || {}).length === 0) + ) { + // Create the headers object with the Host value + apiConfig.openAiHeaders = { Host: configAny.openAiHostHeader } + + // Delete the old property to prevent re-migration + // This prevents the header from reappearing after deletion + configAny.openAiHostHeader = undefined + } + } + } catch (error) { + console.error(`[MigrateOpenAiHeaders] Failed to migrate OpenAI headers:`, error) + } + } + /** * List all available configs with metadata. */ diff --git a/src/core/config/__tests__/ProviderSettingsManager.test.ts b/src/core/config/__tests__/ProviderSettingsManager.test.ts index ade40c29d3..3cacc4c8b7 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.test.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.test.ts @@ -56,6 +56,7 @@ describe("ProviderSettingsManager", () => { migrations: { rateLimitSecondsMigrated: true, diffSettingsMigrated: true, + openAiHeadersMigrated: true, }, }), ) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 94bd68f9c9..cee8ec7618 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -310,7 +310,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We const openAiModels = await getOpenAiModels( message?.values?.baseUrl, message?.values?.apiKey, - message?.values?.hostHeader, + message?.values?.openAiHeaders, ) provider.postMessageToWebview({ type: "openAiModels", openAiModels }) diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 04d713005e..6606e6053b 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -50,7 +50,6 @@ type ProviderSettings = { vertexRegion?: string | undefined openAiBaseUrl?: string | undefined openAiApiKey?: string | undefined - openAiHostHeader?: string | undefined openAiLegacyFormat?: boolean | undefined openAiR1FormatEnabled?: boolean | undefined openAiModelId?: string | undefined @@ -88,6 +87,12 @@ type ProviderSettings = { azureApiVersion?: string | undefined openAiStreamingEnabled?: boolean | undefined enableReasoningEffort?: boolean | undefined + openAiHostHeader?: string | undefined + openAiHeaders?: + | { + [x: string]: string + } + | undefined ollamaModelId?: string | undefined ollamaBaseUrl?: string | undefined vsCodeLmModelSelector?: diff --git a/src/exports/types.ts b/src/exports/types.ts index 7eee7d3cdf..5bafc8da56 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -51,7 +51,6 @@ type ProviderSettings = { vertexRegion?: string | undefined openAiBaseUrl?: string | undefined openAiApiKey?: string | undefined - openAiHostHeader?: string | undefined openAiLegacyFormat?: boolean | undefined openAiR1FormatEnabled?: boolean | undefined openAiModelId?: string | undefined @@ -89,6 +88,12 @@ type ProviderSettings = { azureApiVersion?: string | undefined openAiStreamingEnabled?: boolean | undefined enableReasoningEffort?: boolean | undefined + openAiHostHeader?: string | undefined + openAiHeaders?: + | { + [x: string]: string + } + | undefined ollamaModelId?: string | undefined ollamaBaseUrl?: string | undefined vsCodeLmModelSelector?: diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 00c7919018..96a19b61d8 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -370,7 +370,6 @@ export const providerSettingsSchema = z.object({ // OpenAI openAiBaseUrl: z.string().optional(), openAiApiKey: z.string().optional(), - openAiHostHeader: z.string().optional(), openAiLegacyFormat: z.boolean().optional(), openAiR1FormatEnabled: z.boolean().optional(), openAiModelId: z.string().optional(), @@ -379,6 +378,8 @@ export const providerSettingsSchema = z.object({ azureApiVersion: z.string().optional(), openAiStreamingEnabled: z.boolean().optional(), enableReasoningEffort: z.boolean().optional(), + openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration + openAiHeaders: z.record(z.string(), z.string()).optional(), // Ollama ollamaModelId: z.string().optional(), ollamaBaseUrl: z.string().optional(), @@ -469,7 +470,6 @@ const providerSettingsRecord: ProviderSettingsRecord = { // OpenAI openAiBaseUrl: undefined, openAiApiKey: undefined, - openAiHostHeader: undefined, openAiLegacyFormat: undefined, openAiR1FormatEnabled: undefined, openAiModelId: undefined, @@ -478,6 +478,8 @@ const providerSettingsRecord: ProviderSettingsRecord = { azureApiVersion: undefined, openAiStreamingEnabled: undefined, enableReasoningEffort: undefined, + openAiHostHeader: undefined, // Keep temporarily for backward compatibility during migration + openAiHeaders: undefined, // Ollama ollamaModelId: undefined, ollamaBaseUrl: undefined, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 917947ac05..363e4db6d0 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -3,7 +3,13 @@ import { useDebounce, useEvent } from "react-use" import { Trans } from "react-i18next" import { LanguageModelChatSelector } from "vscode" import { Checkbox } from "vscrui" -import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { + VSCodeButton, + VSCodeLink, + VSCodeRadio, + VSCodeRadioGroup, + VSCodeTextField, +} from "@vscode/webview-ui-toolkit/react" import { ExternalLinkIcon } from "@radix-ui/react-icons" import { ReasoningEffort as ReasoningEffortType } from "@roo/schemas" @@ -77,7 +83,6 @@ const ApiOptions = ({ const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) - const [openAiHostHeaderSelected, setOpenAiHostHeaderSelected] = useState(!!apiConfiguration?.openAiHostHeader) const [openAiLegacyFormatSelected, setOpenAiLegacyFormatSelected] = useState(!!apiConfiguration?.openAiLegacyFormat) const [googleGeminiBaseUrlSelected, setGoogleGeminiBaseUrlSelected] = useState( !!apiConfiguration?.googleGeminiBaseUrl, @@ -123,7 +128,8 @@ const ApiOptions = ({ values: { baseUrl: apiConfiguration?.openAiBaseUrl, apiKey: apiConfiguration?.openAiApiKey, - hostHeader: apiConfiguration?.openAiHostHeader, + customHeaders: {}, // Reserved for any additional headers + openAiHeaders: apiConfiguration?.openAiHeaders, }, }) } else if (selectedProvider === "ollama") { @@ -829,25 +835,90 @@ const ApiOptions = ({ )} -
- { - setOpenAiHostHeaderSelected(checked) - - if (!checked) { - setApiConfigurationField("openAiHostHeader", "") - } - }}> - {t("settings:providers.useHostHeader")} - - {openAiHostHeaderSelected && ( - + {/* Custom Headers UI */} +
+
+ + { + const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) } + // Use an empty string as key - user will fill it in + // The key will be properly set when the user types in the field + currentHeaders[""] = "" + handleInputChange("openAiHeaders")({ + target: { + value: currentHeaders, + }, + }) + }}> + + +
+ {Object.keys(apiConfiguration?.openAiHeaders || {}).length === 0 ? ( +
+ {t("settings:providers.noCustomHeaders")} +
+ ) : ( + Object.entries(apiConfiguration?.openAiHeaders || {}).map(([key, value], index) => ( +
+ { + const currentHeaders = apiConfiguration?.openAiHeaders ?? {} + const newValue = e.target.value + + if (newValue && newValue !== key) { + const { [key]: _, ...rest } = currentHeaders + handleInputChange("openAiHeaders")({ + target: { + value: { + ...rest, + [newValue]: value, + }, + }, + }) + } + }} + /> + { + handleInputChange("openAiHeaders")({ + target: { + value: { + ...(apiConfiguration?.openAiHeaders ?? {}), + [key]: e.target.value, + }, + }, + }) + }} + /> + { + const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {} + + // Ensure we always maintain an empty object even when removing the last header + // This prevents the field from becoming undefined or null + const newHeaders = Object.keys(rest).length === 0 ? {} : rest + + handleInputChange("openAiHeaders")({ + target: { + value: newHeaders, + }, + }) + }}> + + +
+ )) )}
diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index a389611864..7f26f797c1 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -4,7 +4,9 @@ "done": "Fet", "cancel": "Cancel·lar", "reset": "Restablir", - "select": "Seleccionar" + "select": "Seleccionar", + "add": "Afegir capçalera", + "remove": "Eliminar" }, "header": { "title": "Configuració", @@ -107,6 +109,10 @@ "useCustomBaseUrl": "Utilitzar URL base personalitzada", "useHostHeader": "Utilitzar capçalera Host personalitzada", "useLegacyFormat": "Utilitzar el format d'API OpenAI antic", + "customHeaders": "Capçaleres personalitzades", + "headerName": "Nom de la capçalera", + "headerValue": "Valor de la capçalera", + "noCustomHeaders": "No hi ha capçaleres personalitzades definides. Feu clic al botó + per afegir-ne una.", "openRouterTransformsText": "Comprimir prompts i cadenes de missatges a la mida del context (Transformacions d'OpenRouter)", "model": "Model", "getOpenRouterApiKey": "Obtenir clau API d'OpenRouter", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index e0f470e8e0..6447004a22 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -4,7 +4,9 @@ "done": "Fertig", "cancel": "Abbrechen", "reset": "Zurücksetzen", - "select": "Auswählen" + "select": "Auswählen", + "add": "Header hinzufügen", + "remove": "Entfernen" }, "header": { "title": "Einstellungen", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Benutzerdefinierte Basis-URL verwenden", "useHostHeader": "Benutzerdefinierten Host-Header verwenden", "useLegacyFormat": "Altes OpenAI API-Format verwenden", + "customHeaders": "Benutzerdefinierte Headers", + "headerName": "Header-Name", + "headerValue": "Header-Wert", + "noCustomHeaders": "Keine benutzerdefinierten Headers definiert. Klicke auf die + Schaltfläche, um einen hinzuzufügen.", "requestyApiKey": "Requesty API-Schlüssel", "getRequestyApiKey": "Requesty API-Schlüssel erhalten", "openRouterTransformsText": "Prompts und Nachrichtenketten auf Kontextgröße komprimieren (OpenRouter Transformationen)", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b057d94043..3701d8a69c 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -4,7 +4,9 @@ "done": "Done", "cancel": "Cancel", "reset": "Reset", - "select": "Select" + "select": "Select", + "add": "Add Header", + "remove": "Remove" }, "header": { "title": "Settings", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Use custom base URL", "useHostHeader": "Use custom Host header", "useLegacyFormat": "Use legacy OpenAI API format", + "customHeaders": "Custom Headers", + "headerName": "Header name", + "headerValue": "Header value", + "noCustomHeaders": "No custom headers defined. Click the + button to add one.", "requestyApiKey": "Requesty API Key", "getRequestyApiKey": "Get Requesty API Key", "openRouterTransformsText": "Compress prompts and message chains to the context size (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 5b810ea168..7108f60a1b 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -4,7 +4,9 @@ "done": "Hecho", "cancel": "Cancelar", "reset": "Restablecer", - "select": "Seleccionar" + "select": "Seleccionar", + "add": "Añadir encabezado", + "remove": "Eliminar" }, "header": { "title": "Configuración", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Usar URL base personalizada", "useHostHeader": "Usar encabezado Host personalizado", "useLegacyFormat": "Usar formato API de OpenAI heredado", + "customHeaders": "Encabezados personalizados", + "headerName": "Nombre del encabezado", + "headerValue": "Valor del encabezado", + "noCustomHeaders": "No hay encabezados personalizados definidos. Haga clic en el botón + para añadir uno.", "requestyApiKey": "Clave API de Requesty", "getRequestyApiKey": "Obtener clave API de Requesty", "openRouterTransformsText": "Comprimir prompts y cadenas de mensajes al tamaño del contexto (Transformaciones de OpenRouter)", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 25efb24c1f..f81424539d 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -4,7 +4,9 @@ "done": "Terminé", "cancel": "Annuler", "reset": "Réinitialiser", - "select": "Sélectionner" + "select": "Sélectionner", + "add": "Ajouter un en-tête", + "remove": "Supprimer" }, "header": { "title": "Paramètres", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Utiliser une URL de base personnalisée", "useHostHeader": "Utiliser un en-tête Host personnalisé", "useLegacyFormat": "Utiliser le format API OpenAI hérité", + "customHeaders": "En-têtes personnalisés", + "headerName": "Nom de l'en-tête", + "headerValue": "Valeur de l'en-tête", + "noCustomHeaders": "Aucun en-tête personnalisé défini. Cliquez sur le bouton + pour en ajouter un.", "requestyApiKey": "Clé API Requesty", "getRequestyApiKey": "Obtenir la clé API Requesty", "openRouterTransformsText": "Compresser les prompts et chaînes de messages à la taille du contexte (Transformations OpenRouter)", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 5cc20642da..eda9195811 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -4,7 +4,9 @@ "done": "पूर्ण", "cancel": "रद्द करें", "reset": "रीसेट करें", - "select": "चुनें" + "select": "चुनें", + "add": "हेडर जोड़ें", + "remove": "हटाएं" }, "header": { "title": "सेटिंग्स", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "कस्टम बेस URL का उपयोग करें", "useHostHeader": "कस्टम होस्ट हेडर का उपयोग करें", "useLegacyFormat": "पुराने OpenAI API प्रारूप का उपयोग करें", + "customHeaders": "कस्टम हेडर्स", + "headerName": "हेडर नाम", + "headerValue": "हेडर मूल्य", + "noCustomHeaders": "कोई कस्टम हेडर परिभाषित नहीं है। एक जोड़ने के लिए + बटन पर क्लिक करें।", "requestyApiKey": "Requesty API कुंजी", "getRequestyApiKey": "Requesty API कुंजी प्राप्त करें", "openRouterTransformsText": "संदर्भ आकार के लिए प्रॉम्प्ट और संदेश श्रृंखलाओं को संपीड़ित करें (OpenRouter ट्रांसफॉर्म)", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 55866e5350..24a2bd210e 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -4,7 +4,9 @@ "done": "Fatto", "cancel": "Annulla", "reset": "Ripristina", - "select": "Seleziona" + "select": "Seleziona", + "add": "Aggiungi intestazione", + "remove": "Rimuovi" }, "header": { "title": "Impostazioni", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Usa URL base personalizzato", "useHostHeader": "Usa intestazione Host personalizzata", "useLegacyFormat": "Usa formato API OpenAI legacy", + "customHeaders": "Intestazioni personalizzate", + "headerName": "Nome intestazione", + "headerValue": "Valore intestazione", + "noCustomHeaders": "Nessuna intestazione personalizzata definita. Fai clic sul pulsante + per aggiungerne una.", "requestyApiKey": "Chiave API Requesty", "getRequestyApiKey": "Ottieni chiave API Requesty", "openRouterTransformsText": "Comprimi prompt e catene di messaggi alla dimensione del contesto (Trasformazioni OpenRouter)", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index b7960b9b45..84fa016eda 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -4,7 +4,9 @@ "done": "完了", "cancel": "キャンセル", "reset": "リセット", - "select": "選択" + "select": "選択", + "add": "ヘッダーを追加", + "remove": "削除" }, "header": { "title": "設定", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "カスタムベースURLを使用", "useHostHeader": "カスタムHostヘッダーを使用", "useLegacyFormat": "レガシーOpenAI API形式を使用", + "customHeaders": "カスタムヘッダー", + "headerName": "ヘッダー名", + "headerValue": "ヘッダー値", + "noCustomHeaders": "カスタムヘッダーが定義されていません。+ ボタンをクリックして追加してください。", "requestyApiKey": "Requesty APIキー", "getRequestyApiKey": "Requesty APIキーを取得", "openRouterTransformsText": "プロンプトとメッセージチェーンをコンテキストサイズに圧縮 (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index b513a308ce..48123b7670 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -4,7 +4,9 @@ "done": "완료", "cancel": "취소", "reset": "초기화", - "select": "선택" + "select": "선택", + "add": "헤더 추가", + "remove": "삭제" }, "header": { "title": "설정", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "사용자 정의 기본 URL 사용", "useHostHeader": "사용자 정의 Host 헤더 사용", "useLegacyFormat": "레거시 OpenAI API 형식 사용", + "customHeaders": "사용자 정의 헤더", + "headerName": "헤더 이름", + "headerValue": "헤더 값", + "noCustomHeaders": "정의된 사용자 정의 헤더가 없습니다. + 버튼을 클릭하여 추가하세요.", "requestyApiKey": "Requesty API 키", "getRequestyApiKey": "Requesty API 키 받기", "openRouterTransformsText": "프롬프트와 메시지 체인을 컨텍스트 크기로 압축 (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 8f80043fa0..5128a70032 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -4,7 +4,9 @@ "done": "Gotowe", "cancel": "Anuluj", "reset": "Resetuj", - "select": "Wybierz" + "select": "Wybierz", + "add": "Dodaj nagłówek", + "remove": "Usuń" }, "header": { "title": "Ustawienia", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Użyj niestandardowego URL bazowego", "useHostHeader": "Użyj niestandardowego nagłówka Host", "useLegacyFormat": "Użyj starszego formatu API OpenAI", + "customHeaders": "Niestandardowe nagłówki", + "headerName": "Nazwa nagłówka", + "headerValue": "Wartość nagłówka", + "noCustomHeaders": "Brak zdefiniowanych niestandardowych nagłówków. Kliknij przycisk +, aby dodać.", "requestyApiKey": "Klucz API Requesty", "getRequestyApiKey": "Uzyskaj klucz API Requesty", "openRouterTransformsText": "Kompresuj podpowiedzi i łańcuchy wiadomości do rozmiaru kontekstu (Transformacje OpenRouter)", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index d27a053247..e7439bfef7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -4,7 +4,9 @@ "done": "Concluído", "cancel": "Cancelar", "reset": "Redefinir", - "select": "Selecionar" + "select": "Selecionar", + "add": "Adicionar cabeçalho", + "remove": "Remover" }, "header": { "title": "Configurações", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Usar URL base personalizado", "useHostHeader": "Usar cabeçalho Host personalizado", "useLegacyFormat": "Usar formato de API OpenAI legado", + "customHeaders": "Cabeçalhos personalizados", + "headerName": "Nome do cabeçalho", + "headerValue": "Valor do cabeçalho", + "noCustomHeaders": "Nenhum cabeçalho personalizado definido. Clique no botão + para adicionar um.", "requestyApiKey": "Chave de API Requesty", "getRequestyApiKey": "Obter chave de API Requesty", "openRouterTransformsText": "Comprimir prompts e cadeias de mensagens para o tamanho do contexto (Transformações OpenRouter)", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 27f1240a89..9a72541eae 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -4,7 +4,9 @@ "done": "Готово", "cancel": "Отмена", "reset": "Сбросить", - "select": "Выбрать" + "select": "Выбрать", + "add": "Добавить заголовок", + "remove": "Удалить" }, "header": { "title": "Настройки", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Использовать пользовательский базовый URL", "useHostHeader": "Использовать пользовательский Host-заголовок", "useLegacyFormat": "Использовать устаревший формат OpenAI API", + "customHeaders": "Пользовательские заголовки", + "headerName": "Имя заголовка", + "headerValue": "Значение заголовка", + "noCustomHeaders": "Пользовательские заголовки не определены. Нажмите кнопку +, чтобы добавить.", "requestyApiKey": "Requesty API-ключ", "getRequestyApiKey": "Получить Requesty API-ключ", "openRouterTransformsText": "Сжимать подсказки и цепочки сообщений до размера контекста (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 94861a6139..3d55385faf 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -4,7 +4,9 @@ "done": "Tamamlandı", "cancel": "İptal", "reset": "Sıfırla", - "select": "Seç" + "select": "Seç", + "add": "Başlık Ekle", + "remove": "Kaldır" }, "header": { "title": "Ayarlar", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Özel temel URL kullan", "useHostHeader": "Özel Host başlığı kullan", "useLegacyFormat": "Eski OpenAI API formatını kullan", + "customHeaders": "Özel Başlıklar", + "headerName": "Başlık adı", + "headerValue": "Başlık değeri", + "noCustomHeaders": "Tanımlanmış özel başlık yok. Eklemek için + düğmesine tıklayın.", "requestyApiKey": "Requesty API Anahtarı", "getRequestyApiKey": "Requesty API Anahtarı Al", "openRouterTransformsText": "İstem ve mesaj zincirlerini bağlam boyutuna sıkıştır (OpenRouter Dönüşümleri)", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 87941b5b8e..c70115e77e 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -4,7 +4,9 @@ "done": "Hoàn thành", "cancel": "Hủy", "reset": "Đặt lại", - "select": "Chọn" + "select": "Chọn", + "add": "Thêm tiêu đề", + "remove": "Xóa" }, "header": { "title": "Cài đặt", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "Sử dụng URL cơ sở tùy chỉnh", "useHostHeader": "Sử dụng tiêu đề Host tùy chỉnh", "useLegacyFormat": "Sử dụng định dạng API OpenAI cũ", + "customHeaders": "Tiêu đề tùy chỉnh", + "headerName": "Tên tiêu đề", + "headerValue": "Giá trị tiêu đề", + "noCustomHeaders": "Chưa có tiêu đề tùy chỉnh nào được định nghĩa. Nhấp vào nút + để thêm.", "requestyApiKey": "Khóa API Requesty", "getRequestyApiKey": "Lấy khóa API Requesty", "anthropicApiKey": "Khóa API Anthropic", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index fd10b77b63..f9aa5177e0 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -4,7 +4,9 @@ "done": "完成", "cancel": "取消", "reset": "恢复默认设置", - "select": "选择" + "select": "选择", + "add": "添加标头", + "remove": "移除" }, "header": { "title": "设置", @@ -109,6 +111,10 @@ "useCustomBaseUrl": "使用自定义基础 URL", "useHostHeader": "使用自定义 Host 标头", "useLegacyFormat": "使用传统 OpenAI API 格式", + "customHeaders": "自定义标头", + "headerName": "标头名称", + "headerValue": "标头值", + "noCustomHeaders": "暂无自定义标头。点击 + 按钮添加。", "glamaApiKey": "Glama API 密钥", "getGlamaApiKey": "获取 Glama API 密钥", "requestyApiKey": "Requesty API 密钥", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index a40be4d17f..1d329752fe 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -4,7 +4,9 @@ "done": "完成", "cancel": "取消", "reset": "重設", - "select": "選擇" + "select": "選擇", + "add": "新增標頭", + "remove": "移除" }, "header": { "title": "設定", @@ -111,6 +113,10 @@ "useCustomBaseUrl": "使用自訂基礎 URL", "useHostHeader": "使用自訂 Host 標頭", "useLegacyFormat": "使用舊版 OpenAI API 格式", + "customHeaders": "自訂標頭", + "headerName": "標頭名稱", + "headerValue": "標頭值", + "noCustomHeaders": "尚未定義自訂標頭。點擊 + 按鈕以新增。", "requestyApiKey": "Requesty API 金鑰", "getRequestyApiKey": "取得 Requesty API 金鑰", "openRouterTransformsText": "將提示和訊息鏈壓縮到上下文大小 (OpenRouter 轉換)", From e5ad87990d0a6aa7064aa16cafe41705bfb4a2ed Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 30 Apr 2025 22:16:10 -0400 Subject: [PATCH 2/4] PR feedback --- .../src/components/settings/ApiOptions.tsx | 133 +++++++++++------- 1 file changed, 80 insertions(+), 53 deletions(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 363e4db6d0..a643dae7b0 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -80,6 +80,11 @@ const ApiOptions = ({ const [openAiModels, setOpenAiModels] = useState | null>(null) + const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { + const headers = apiConfiguration?.openAiHeaders || {} + return Object.entries(headers) + }) + const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) @@ -87,6 +92,71 @@ const ApiOptions = ({ const [googleGeminiBaseUrlSelected, setGoogleGeminiBaseUrlSelected] = useState( !!apiConfiguration?.googleGeminiBaseUrl, ) + + const handleAddCustomHeader = useCallback(() => { + // Only update the local state to show the new row in the UI + setCustomHeaders((prev) => [...prev, ["", ""]]) + // Do not update the main configuration yet, wait for user input + }, []) + + const handleUpdateHeaderKey = useCallback((index: number, newKey: string) => { + setCustomHeaders((prev) => { + const updated = [...prev] + if (updated[index]) { + updated[index] = [newKey, updated[index][1]] + } + return updated + }) + }, []) + + const handleUpdateHeaderValue = useCallback((index: number, newValue: string) => { + setCustomHeaders((prev) => { + const updated = [...prev] + if (updated[index]) { + updated[index] = [updated[index][0], newValue] + } + return updated + }) + }, []) + + const handleRemoveCustomHeader = useCallback((index: number) => { + setCustomHeaders((prev) => prev.filter((_, i) => i !== index)) + }, []) + + // Helper to convert array of tuples to object (filtering out empty keys) + const convertHeadersToObject = (headers: [string, string][]): Record => { + const result: Record = {} + + // Process each header tuple + for (const [key, value] of headers) { + const trimmedKey = key.trim() + + // Skip empty keys + if (!trimmedKey) continue + + // For duplicates, the last one in the array wins + // This matches how HTTP headers work in general + result[trimmedKey] = value.trim() + } + + return result + } + + // Debounced effect to update the main configuration when local customHeaders state stabilizes + useDebounce( + () => { + const currentConfigHeaders = apiConfiguration?.openAiHeaders || {} + const newHeadersObject = convertHeadersToObject(customHeaders) + + // Only update if the processed object is different from the current config + if (JSON.stringify(currentConfigHeaders) !== JSON.stringify(newHeadersObject)) { + setApiConfigurationField("openAiHeaders", newHeadersObject) + } + }, + 300, + [customHeaders, apiConfiguration?.openAiHeaders, setApiConfigurationField], + ) + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) const noTransform = (value: T) => value @@ -123,13 +193,15 @@ const ApiOptions = ({ useDebounce( () => { if (selectedProvider === "openai") { + // Use our custom headers state to build the headers object + const headerObject = convertHeadersToObject(customHeaders) vscode.postMessage({ type: "requestOpenAiModels", values: { baseUrl: apiConfiguration?.openAiBaseUrl, apiKey: apiConfiguration?.openAiApiKey, customHeaders: {}, // Reserved for any additional headers - openAiHeaders: apiConfiguration?.openAiHeaders, + openAiHeaders: headerObject, }, }) } else if (selectedProvider === "ollama") { @@ -148,6 +220,7 @@ const ApiOptions = ({ apiConfiguration?.openAiApiKey, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl, + customHeaders, ], ) @@ -842,79 +915,33 @@ const ApiOptions = ({ { - const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) } - // Use an empty string as key - user will fill it in - // The key will be properly set when the user types in the field - currentHeaders[""] = "" - handleInputChange("openAiHeaders")({ - target: { - value: currentHeaders, - }, - }) - }}> + onClick={handleAddCustomHeader}>
- {Object.keys(apiConfiguration?.openAiHeaders || {}).length === 0 ? ( + {!customHeaders.length ? (
{t("settings:providers.noCustomHeaders")}
) : ( - Object.entries(apiConfiguration?.openAiHeaders || {}).map(([key, value], index) => ( + customHeaders.map(([key, value], index) => (
{ - const currentHeaders = apiConfiguration?.openAiHeaders ?? {} - const newValue = e.target.value - - if (newValue && newValue !== key) { - const { [key]: _, ...rest } = currentHeaders - handleInputChange("openAiHeaders")({ - target: { - value: { - ...rest, - [newValue]: value, - }, - }, - }) - } - }} + onInput={(e: any) => handleUpdateHeaderKey(index, e.target.value)} /> { - handleInputChange("openAiHeaders")({ - target: { - value: { - ...(apiConfiguration?.openAiHeaders ?? {}), - [key]: e.target.value, - }, - }, - }) - }} + onInput={(e: any) => handleUpdateHeaderValue(index, e.target.value)} /> { - const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {} - - // Ensure we always maintain an empty object even when removing the last header - // This prevents the field from becoming undefined or null - const newHeaders = Object.keys(rest).length === 0 ? {} : rest - - handleInputChange("openAiHeaders")({ - target: { - value: newHeaders, - }, - }) - }}> + onClick={() => handleRemoveCustomHeader(index)}>
From f9dcc36869198f602b155d246d8b730752721eaa Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 30 Apr 2025 23:42:08 -0400 Subject: [PATCH 3/4] Fix migration --- webview-ui/src/components/settings/ApiOptions.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index a643dae7b0..4f0e6de774 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -85,6 +85,12 @@ const ApiOptions = ({ return Object.entries(headers) }) + // Effect to synchronize internal customHeaders state with prop changes + useEffect(() => { + const propHeaders = apiConfiguration?.openAiHeaders || {} + setCustomHeaders(Object.entries(propHeaders)) + }, [apiConfiguration?.openAiHeaders]) + const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) From c7603738f4733cdaf45d3f05385b840a4ef3be58 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 30 Apr 2025 23:52:27 -0400 Subject: [PATCH 4/4] Update webview-ui/src/components/settings/ApiOptions.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- webview-ui/src/components/settings/ApiOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 4f0e6de774..3958c1f046 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -88,7 +88,7 @@ const ApiOptions = ({ // Effect to synchronize internal customHeaders state with prop changes useEffect(() => { const propHeaders = apiConfiguration?.openAiHeaders || {} - setCustomHeaders(Object.entries(propHeaders)) + if (JSON.stringify(customHeaders) !== JSON.stringify(Object.entries(propHeaders))) setCustomHeaders(Object.entries(propHeaders)) }, [apiConfiguration?.openAiHeaders]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)