Skip to content

@langchain/openai: a caller-supplied User-Agent is emitted as a second header, so the transport sends a comma-joined value #11438

Description

Checked other resources

  • I added a very descriptive title to this issue.
  • I searched the LangChain.js documentation with the integrated search.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain.js rather than my code.

Example Code

getHeadersWithUserAgent in libs/providers/langchain-openai/src/utils/azure.ts is used by every OpenAI and Azure OpenAI client to build defaultHeaders. When the caller supplies their own User-Agent, the result carries two user-agent entries:

import { getHeadersWithUserAgent } from "@langchain/openai/utils/azure";

const headers = getHeadersWithUserAgent({ "User-Agent": "my-app/1.2" });

console.log(headers);
// {
//   "user-agent": "my-app/1.2",
//   "User-Agent": "langchainjs-openai/1.0.0 ((node/v22.22.0; darwin; x64))"
// }

// What the transport actually sends:
console.log(new Headers(headers).get("user-agent"));
// "my-app/1.2, langchainjs-openai/1.0.0 ((node/v22.22.0; darwin; x64))"

Every OpenAI and Azure OpenAI client passes its defaultHeaders through this function before constructing the underlying SDK client, e.g. libs/providers/langchain-openai/src/chat_models/base.ts:

params.defaultHeaders = getHeadersWithUserAgent(params.defaultHeaders);

this.client = new OpenAIClient(params);

so anything set via configuration.defaultHeaders is affected:

new ChatOpenAI({
  apiKey: "sk-test",
  configuration: { defaultHeaders: { "User-Agent": "my-app/1.2" } },
});

Error Message and Stack Trace (if applicable)

No error is thrown — the header is silently malformed.

Description

The current implementation reads the caller's user agent back out of the normalized record using the canonical casing:

const normalizedHeaders = normalizeHeaders(headers);
// ...
return {
  ...normalizedHeaders,
  "User-Agent": normalizedHeaders["User-Agent"]
    ? `${library}/${version} (${env})${normalizedHeaders["User-Agent"]}`
    : `${library}/${version} (${env})`,
};

normalizeHeaders builds a WHATWG Headers instance and returns Object.fromEntries(output.entries()). Headers lowercases names on construction, so the record is keyed user-agent, never User-Agent. Three consequences:

  1. The lookup never matches. normalizedHeaders["User-Agent"] is always undefined, so the ternary's truthy branch is unreachable — a caller's user agent is never appended, whatever casing they use.
  2. Two user-agent keys are emitted. The spread keeps the caller's user-agent, and User-Agent is then added beside it. Both survive into defaultHeaders, and a transport collapses them into one comma-joined value. RFC 9110 §10.1.5 defines User-Agent as product tokens separated by whitespace, so the comma-joined form is malformed and breaks user-agent parsing/attribution on the provider side.
  3. The unreachable branch is also missing a separator. Were it reached, it would produce langchainjs-openai/1.0.0 (env)my-app/1.2 with the caller's token glued to the closing parenthesis.

The intent of the ternary is clearly to prepend the library user agent to the caller's; it just never fires. This affects all six call sites — ChatOpenAI, AzureChatOpenAI, OpenAIEmbeddings, AzureOpenAIEmbeddings, OpenAI and AzureOpenAI LLMs — so it applies to plain OpenAI usage as well as Azure.

Expected: exactly one User-Agent header, with the caller's token appended after the library's and separated by a space.

System Info

platform: mac
node: v22.22.0
@langchain/openai: workspace (main @ fca7d2f8b)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions