Skip to content

Commit 1d9e368

Browse files
fix: respect user-specified User-Agent headers without modification (OpenRouterTeam#362)
* fix: respect user-specified User-Agent headers without modification - Add case-insensitive lookup for User-Agent header - When user provides User-Agent (any case), use it verbatim without SDK suffix - Only add SDK identifier as default when no User-Agent is provided - Add comprehensive tests for withUserAgentSuffix function Fixes OpenRouterTeam#300 Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai> * fix: handle all HeadersInit variants and fix removeUndefinedEntries - Add normalizeHeaders() to properly handle Headers objects and array-of-tuples - Fix removeUndefinedEntries to filter both null and undefined values - Add tests for Headers object and array-of-tuples inputs - Update test to verify undefined keys are actually removed from result Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 21d3185 commit 1d9e368

File tree

4 files changed

+209
-15
lines changed

4 files changed

+209
-15
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@openrouter/ai-sdk-provider": patch
3+
---
4+
5+
fix: respect user-specified User-Agent headers without modification
6+
7+
Previously, when users provided a custom `User-Agent` header via `createOpenRouter({ headers: { 'User-Agent': 'my-app/1.0' } })`, the SDK would append its identifier to the header, resulting in `my-app/1.0, ai-sdk/openrouter/x.x.x`. This was unexpected behavior.
8+
9+
Now, user-specified `User-Agent` headers are used verbatim without modification. The SDK identifier is only added as the default when no `User-Agent` header is provided.
10+
11+
This also fixes a case-sensitivity bug where `User-Agent` (capitalized) was not recognized as the same header as `user-agent` (lowercase), causing duplicate headers to be sent.
12+
13+
Fixes #300

src/utils/remove-undefined.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ export function removeUndefinedEntries<T>(
77
record: Record<string, T | undefined>,
88
): Record<string, T> {
99
return Object.fromEntries(
10-
Object.entries(record).filter(([, value]) => value !== null),
10+
Object.entries(record).filter(([, value]) => value != null),
1111
) as Record<string, T>;
1212
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { withUserAgentSuffix } from './with-user-agent-suffix';
3+
4+
describe('withUserAgentSuffix', () => {
5+
const SDK_SUFFIX = 'ai-sdk/openrouter/1.0.0';
6+
7+
describe('when no user-agent header is provided', () => {
8+
it('should add SDK identifier as the user-agent', () => {
9+
const result = withUserAgentSuffix({}, SDK_SUFFIX);
10+
expect(result['user-agent']).toBe(SDK_SUFFIX);
11+
});
12+
13+
it('should add SDK identifier when headers is undefined', () => {
14+
const result = withUserAgentSuffix(undefined, SDK_SUFFIX);
15+
expect(result['user-agent']).toBe(SDK_SUFFIX);
16+
});
17+
18+
it('should preserve other headers while adding user-agent', () => {
19+
const result = withUserAgentSuffix(
20+
{ Authorization: 'Bearer token', 'Content-Type': 'application/json' },
21+
SDK_SUFFIX,
22+
);
23+
expect(result['user-agent']).toBe(SDK_SUFFIX);
24+
expect(result['Authorization']).toBe('Bearer token');
25+
expect(result['Content-Type']).toBe('application/json');
26+
});
27+
});
28+
29+
describe('when user provides user-agent header', () => {
30+
it('should use user-provided lowercase user-agent verbatim without SDK suffix', () => {
31+
const result = withUserAgentSuffix(
32+
{ 'user-agent': 'my-custom-agent/1.0' },
33+
SDK_SUFFIX,
34+
);
35+
expect(result['user-agent']).toBe('my-custom-agent/1.0');
36+
});
37+
38+
it('should use user-provided capitalized User-Agent verbatim without SDK suffix', () => {
39+
const result = withUserAgentSuffix(
40+
{ 'User-Agent': 'my-custom-agent/1.0' },
41+
SDK_SUFFIX,
42+
);
43+
expect(result['user-agent']).toBe('my-custom-agent/1.0');
44+
// Should not have duplicate header with different casing
45+
expect(result['User-Agent']).toBeUndefined();
46+
});
47+
48+
it('should use user-provided mixed case USER-AGENT verbatim without SDK suffix', () => {
49+
const result = withUserAgentSuffix(
50+
{ 'USER-AGENT': 'my-custom-agent/1.0' },
51+
SDK_SUFFIX,
52+
);
53+
expect(result['user-agent']).toBe('my-custom-agent/1.0');
54+
// Should not have duplicate header with different casing
55+
expect(result['USER-AGENT']).toBeUndefined();
56+
});
57+
58+
it('should preserve other headers when user provides User-Agent', () => {
59+
const result = withUserAgentSuffix(
60+
{
61+
'User-Agent': 'my-custom-agent/1.0',
62+
Authorization: 'Bearer token',
63+
},
64+
SDK_SUFFIX,
65+
);
66+
expect(result['user-agent']).toBe('my-custom-agent/1.0');
67+
expect(result['Authorization']).toBe('Bearer token');
68+
});
69+
});
70+
71+
describe('edge cases', () => {
72+
it('should use SDK identifier when user-agent is empty string', () => {
73+
const result = withUserAgentSuffix({ 'user-agent': '' }, SDK_SUFFIX);
74+
expect(result['user-agent']).toBe(SDK_SUFFIX);
75+
});
76+
77+
it('should handle multiple suffix parts when no user-agent provided', () => {
78+
const result = withUserAgentSuffix({}, 'part1', 'part2');
79+
expect(result['user-agent']).toBe('part1 part2');
80+
});
81+
82+
it('should remove undefined header values and not include the key', () => {
83+
const result = withUserAgentSuffix(
84+
{ 'some-header': undefined as unknown as string },
85+
SDK_SUFFIX,
86+
);
87+
expect('some-header' in result).toBe(false);
88+
expect(result['user-agent']).toBe(SDK_SUFFIX);
89+
});
90+
});
91+
92+
describe('HeadersInit variants', () => {
93+
it('should handle Headers object input', () => {
94+
const headers = new Headers({
95+
Authorization: 'Bearer token',
96+
'User-Agent': 'my-custom-agent/1.0',
97+
});
98+
const result = withUserAgentSuffix(headers, SDK_SUFFIX);
99+
expect(result['user-agent']).toBe('my-custom-agent/1.0');
100+
expect(result['authorization']).toBe('Bearer token');
101+
});
102+
103+
it('should handle Headers object without user-agent', () => {
104+
const headers = new Headers({
105+
Authorization: 'Bearer token',
106+
});
107+
const result = withUserAgentSuffix(headers, SDK_SUFFIX);
108+
expect(result['user-agent']).toBe(SDK_SUFFIX);
109+
expect(result['authorization']).toBe('Bearer token');
110+
});
111+
112+
it('should handle array-of-tuples input', () => {
113+
const headers: [string, string][] = [
114+
['Authorization', 'Bearer token'],
115+
['User-Agent', 'my-custom-agent/1.0'],
116+
];
117+
const result = withUserAgentSuffix(headers, SDK_SUFFIX);
118+
expect(result['user-agent']).toBe('my-custom-agent/1.0');
119+
expect(result['Authorization']).toBe('Bearer token');
120+
});
121+
122+
it('should handle array-of-tuples without user-agent', () => {
123+
const headers: [string, string][] = [
124+
['Authorization', 'Bearer token'],
125+
['Content-Type', 'application/json'],
126+
];
127+
const result = withUserAgentSuffix(headers, SDK_SUFFIX);
128+
expect(result['user-agent']).toBe(SDK_SUFFIX);
129+
expect(result['Authorization']).toBe('Bearer token');
130+
expect(result['Content-Type']).toBe('application/json');
131+
});
132+
});
133+
});
Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,78 @@
11
import { removeUndefinedEntries } from '@/src/utils/remove-undefined';
22

33
/**
4-
* Appends suffix parts to the `user-agent` header.
5-
* If a `user-agent` header already exists, the suffix parts are appended to it.
6-
* If no `user-agent` header exists, a new one is created with the suffix parts.
4+
* Normalizes HeadersInit to a plain object.
5+
* Handles Headers objects, array-of-tuples, and plain objects.
6+
* @param headers - The headers in any HeadersInit format.
7+
* @returns A plain object with string keys and values.
8+
*/
9+
function normalizeHeaders(
10+
headers: HeadersInit | Record<string, string | undefined> | undefined,
11+
): Record<string, string | undefined> {
12+
if (!headers) {
13+
return {};
14+
}
15+
if (headers instanceof Headers) {
16+
return Object.fromEntries(headers.entries());
17+
}
18+
if (Array.isArray(headers)) {
19+
return Object.fromEntries(headers);
20+
}
21+
return headers as Record<string, string | undefined>;
22+
}
23+
24+
/**
25+
* Finds a header key in a case-insensitive manner.
26+
* @param headers - The headers object to search.
27+
* @param targetKey - The key to find (case-insensitive).
28+
* @returns The actual key in the headers object, or undefined if not found.
29+
*/
30+
function findHeaderKey(
31+
headers: Record<string, string>,
32+
targetKey: string,
33+
): string | undefined {
34+
const lowerTarget = targetKey.toLowerCase();
35+
return Object.keys(headers).find((key) => key.toLowerCase() === lowerTarget);
36+
}
37+
38+
/**
39+
* Sets the user-agent header, respecting user-specified values.
40+
* If a user explicitly provides a User-Agent header (any case), it is used verbatim.
41+
* If no User-Agent header is provided, the suffix parts are used as the default.
742
* Automatically removes undefined entries from the headers.
843
*
944
* @param headers - The original headers.
10-
* @param userAgentSuffixParts - The parts to append to the `user-agent` header.
11-
* @returns The new headers with the `user-agent` header set or updated.
45+
* @param userAgentSuffixParts - The parts to use as the default user-agent if none is provided.
46+
* @returns The new headers with the `user-agent` header set.
1247
*/
1348
export function withUserAgentSuffix(
1449
headers: HeadersInit | Record<string, string | undefined> | undefined,
1550
...userAgentSuffixParts: string[]
1651
): Record<string, string> {
17-
const cleanedHeaders = removeUndefinedEntries(
18-
(headers as Record<string, string | undefined>) ?? {},
19-
);
52+
const normalizedHeaders = normalizeHeaders(headers);
53+
const cleanedHeaders = removeUndefinedEntries(normalizedHeaders);
2054

21-
const currentUserAgentHeader = cleanedHeaders['user-agent'] || '';
22-
const newUserAgent = [currentUserAgentHeader, ...userAgentSuffixParts]
23-
.filter(Boolean)
24-
.join(' ');
55+
// Find user-agent header with case-insensitive lookup
56+
const existingUserAgentKey = findHeaderKey(cleanedHeaders, 'user-agent');
57+
const existingUserAgentValue = existingUserAgentKey
58+
? cleanedHeaders[existingUserAgentKey]
59+
: undefined;
60+
61+
// If user provided a non-empty User-Agent, use it verbatim
62+
// Otherwise, use the SDK identifier as the default
63+
const userAgent = existingUserAgentValue?.trim()
64+
? existingUserAgentValue
65+
: userAgentSuffixParts.filter(Boolean).join(' ');
66+
67+
// Remove any existing user-agent header (regardless of case) and add normalized one
68+
const headersWithoutUserAgent = Object.fromEntries(
69+
Object.entries(cleanedHeaders).filter(
70+
([key]) => key.toLowerCase() !== 'user-agent',
71+
),
72+
);
2573

2674
return {
27-
...cleanedHeaders,
28-
'user-agent': newUserAgent,
75+
...headersWithoutUserAgent,
76+
'user-agent': userAgent,
2977
};
3078
}

0 commit comments

Comments
 (0)