Skip to content

Commit d9971c2

Browse files
mrubensmehmetsunkur
authored andcommitted
Fix saving of OpenAI compatible headers (RooCodeInc#3415)
* Fix saving of OpenAI compatible headers * Code cleanup * Add test
1 parent 1a4fc2b commit d9971c2

File tree

4 files changed

+162
-20
lines changed

4 files changed

+162
-20
lines changed

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

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { memo, useCallback, useEffect, useMemo, useState } from "react"
2+
import { convertHeadersToObject } from "./utils/headers"
23
import { useDebounce } from "react-use"
34
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
45

@@ -86,25 +87,6 @@ const ApiOptions = ({
8687
}, [apiConfiguration?.openAiHeaders, customHeaders])
8788

8889
// Helper to convert array of tuples to object (filtering out empty keys).
89-
const convertHeadersToObject = (headers: [string, string][]): Record<string, string> => {
90-
const result: Record<string, string> = {}
91-
92-
// Process each header tuple.
93-
for (const [key, value] of headers) {
94-
const trimmedKey = key.trim()
95-
96-
// Skip empty keys.
97-
if (!trimmedKey) {
98-
continue
99-
}
100-
101-
// For duplicates, the last one in the array wins.
102-
// This matches how HTTP headers work in general.
103-
result[trimmedKey] = value.trim()
104-
}
105-
106-
return result
107-
}
10890

10991
// Debounced effect to update the main configuration when local
11092
// customHeaders state stabilizes.

webview-ui/src/components/settings/providers/OpenAICompatible.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useState, useCallback } from "react"
1+
import { useState, useCallback, useEffect } from "react"
22
import { useEvent } from "react-use"
33
import { Checkbox } from "vscrui"
44
import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
5+
import { convertHeadersToObject } from "../utils/headers"
56

67
import { ModelInfo, ReasoningEffort as ReasoningEffortType } from "@roo/schemas"
78
import { ProviderSettings, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults } from "@roo/shared/api"
@@ -67,6 +68,18 @@ export const OpenAICompatible = ({ apiConfiguration, setApiConfigurationField }:
6768
setCustomHeaders((prev) => prev.filter((_, i) => i !== index))
6869
}, [])
6970

71+
// Helper to convert array of tuples to object
72+
73+
// Add effect to update the parent component's state when local headers change
74+
useEffect(() => {
75+
const timer = setTimeout(() => {
76+
const headerObject = convertHeadersToObject(customHeaders)
77+
setApiConfigurationField("openAiHeaders", headerObject)
78+
}, 300)
79+
80+
return () => clearTimeout(timer)
81+
}, [customHeaders, setApiConfigurationField])
82+
7083
const handleInputChange = useCallback(
7184
<K extends keyof ProviderSettings, E>(
7285
field: K,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { convertHeadersToObject } from "../headers"
2+
3+
describe("convertHeadersToObject", () => {
4+
it("should convert headers array to object", () => {
5+
const headers: [string, string][] = [
6+
["Content-Type", "application/json"],
7+
["Authorization", "Bearer token123"],
8+
]
9+
10+
const result = convertHeadersToObject(headers)
11+
12+
expect(result).toEqual({
13+
"Content-Type": "application/json",
14+
Authorization: "Bearer token123",
15+
})
16+
})
17+
18+
it("should trim whitespace from keys and values", () => {
19+
const headers: [string, string][] = [
20+
[" Content-Type ", " application/json "],
21+
[" Authorization ", " Bearer token123 "],
22+
]
23+
24+
const result = convertHeadersToObject(headers)
25+
26+
expect(result).toEqual({
27+
"Content-Type": "application/json",
28+
Authorization: "Bearer token123",
29+
})
30+
})
31+
32+
it("should handle empty headers array", () => {
33+
const headers: [string, string][] = []
34+
35+
const result = convertHeadersToObject(headers)
36+
37+
expect(result).toEqual({})
38+
})
39+
40+
it("should skip headers with empty keys", () => {
41+
const headers: [string, string][] = [
42+
["Content-Type", "application/json"],
43+
["", "This value should be skipped"],
44+
[" ", "This value should also be skipped"],
45+
["Authorization", "Bearer token123"],
46+
]
47+
48+
const result = convertHeadersToObject(headers)
49+
50+
expect(result).toEqual({
51+
"Content-Type": "application/json",
52+
Authorization: "Bearer token123",
53+
})
54+
55+
// Specifically verify empty keys are not present
56+
expect(result[""]).toBeUndefined()
57+
expect(result[" "]).toBeUndefined()
58+
})
59+
60+
it("should use last occurrence when handling duplicate keys", () => {
61+
const headers: [string, string][] = [
62+
["Content-Type", "application/json"],
63+
["Authorization", "Bearer token123"],
64+
["Content-Type", "text/plain"], // Duplicate key - should override previous value
65+
["Content-Type", "application/xml"], // Another duplicate - should override again
66+
]
67+
68+
const result = convertHeadersToObject(headers)
69+
70+
// Verify the last value for "Content-Type" is used
71+
expect(result["Content-Type"]).toBe("application/xml")
72+
expect(result).toEqual({
73+
"Content-Type": "application/xml",
74+
Authorization: "Bearer token123",
75+
})
76+
})
77+
78+
it("should preserve case sensitivity while trimming keys", () => {
79+
const headers: [string, string][] = [
80+
[" Content-Type", "application/json"],
81+
["content-type ", "text/plain"], // Different casing (lowercase) with spacing
82+
]
83+
84+
const result = convertHeadersToObject(headers)
85+
86+
// Keys should be trimmed but case sensitivity preserved
87+
// JavaScript object keys are case-sensitive
88+
expect(Object.keys(result)).toHaveLength(2)
89+
expect(result["Content-Type"]).toBe("application/json")
90+
expect(result["content-type"]).toBe("text/plain")
91+
})
92+
93+
it("should handle empty values", () => {
94+
const headers: [string, string][] = [
95+
["Empty-Value", ""],
96+
["Whitespace-Value", " "],
97+
]
98+
99+
const result = convertHeadersToObject(headers)
100+
101+
// Empty values should be included but trimmed
102+
expect(result["Empty-Value"]).toBe("")
103+
expect(result["Whitespace-Value"]).toBe("")
104+
})
105+
106+
it("should handle complex duplicate key scenarios with mixed casing and spacing", () => {
107+
const headers: [string, string][] = [
108+
["content-type", "application/json"], // Original entry
109+
[" Content-Type ", "text/html"], // Different case with spacing
110+
["content-type", "application/xml"], // Same case as first, should override it
111+
["Content-Type", "text/plain"], // Same case as second, should override it
112+
]
113+
114+
const result = convertHeadersToObject(headers)
115+
116+
// JavaScript object keys are case-sensitive
117+
// We should have two keys with different cases, each with the last value
118+
expect(Object.keys(result).sort()).toEqual(["Content-Type", "content-type"].sort())
119+
expect(result["content-type"]).toBe("application/xml")
120+
expect(result["Content-Type"]).toBe("text/plain")
121+
})
122+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Converts an array of header key-value pairs to a Record object.
3+
*
4+
* @param headers Array of [key, value] tuples representing HTTP headers
5+
* @returns Record with trimmed keys and values
6+
*/
7+
export const convertHeadersToObject = (headers: [string, string][]): Record<string, string> => {
8+
const result: Record<string, string> = {}
9+
10+
// Process each header tuple.
11+
for (const [key, value] of headers) {
12+
const trimmedKey = key.trim()
13+
14+
// Skip empty keys.
15+
if (!trimmedKey) {
16+
continue
17+
}
18+
19+
// For duplicates, the last one in the array wins.
20+
// This matches how HTTP headers work in general.
21+
result[trimmedKey] = value.trim()
22+
}
23+
24+
return result
25+
}

0 commit comments

Comments
 (0)