model-provider: a configured `anthropic-beta` header is replaced instead of merged, so no Claude beta flag can be requested

Author: sxh313Created Sep 23, 2026Updated Sep 23, 2026

Version

main @ c2ad42e3; multimodal/tarko/model-provider/package.json is @tarko/model-provider version 0.3.0.

Issue Description

For a Claude model, addClaudeHeadersIfNeeded() merges the caller's headers with the automatically generated ones by spreading the automatic block last:

multimodal/tarko/model-provider/src/claude-headers.ts:38-51

typescript
export function addClaudeHeadersIfNeeded(
  model: string,
  existingHeaders?: Record<string, string>,
): Record<string, string> {
  if (!isClaudeModel(model)) {
    return existingHeaders || {};
  }

  const claudeHeaders = getClaudeHeaders();
  return {
    ...existingHeaders,
    ...claudeHeaders,
  };
}

Both objects have the single key 'anthropic-beta' (:30-32), so whenever the caller sets that header itself the second spread silently discards the caller's value. That contradicts the package's own documentation of this function:

  • model-provider/README.md:105 — "Custom headers merge with automatic provider headers"
  • model-provider/src/types.ts:42-44 — /** Additional headers to include in requests */ headers?: Record<string, string>;

and it is the wrong direction for this particular header in particular: anthropic-beta is a comma-separated list, which is exactly how this file builds it (betaFeatures.join(','), :31). A value the caller supplies is another element of that same list, not a competing value for the field — so the two sources can be combined, and replacing one with the other loses data either way.

The practical effect: a user cannot request a Claude beta feature. context-1m-2025-04-11, prompt-caching-2024-07-31, or any other flag has to be sent in anthropic-beta, and any such value written into the model configuration is removed before the request leaves. headers is a supported user-facing option and reaches this function intact:

hop file:line code
1. user-facing option model-provider/src/types.ts:42-44, :50 headers?: Record<string, string> on Model, which AgentModel extends Model
2. exposed as agent options agent-interface/src/agent-options.ts:9, :63, :69 import { AgentModel … } from '@tarko/model-provider/types' → interface AgentModelOptions → model?: AgentModel
3. user config wins into options agent-cli/src/config/builder.ts:51 config = deepMerge(config, userConfig)
4. agent resolves the model agent/src/agent/agent.ts:94, :513 this.currentModel = resolveModel(options.model) / resolveModel(this.options.model, request.model, request.provider)
5. resolver calls this function model-provider/src/model-resolver.ts:75, :86 const headers = addClaudeHeadersIfNeeded(model, agentModel?.headers); → returned as headers
6. headers reach the HTTP client agent/src/agent/llm-client.ts:33 → model-provider/src/llm-client.ts:36 createLLMClient(currentModel, …) → new TokenJS({ apiKey, baseURL, defaultHeaders: headers })

A second, smaller consequence of the same shape: the merge is done by object key, but HTTP field names are case-insensitive, and TypeScript's Record<string, string> does not constrain the casing a caller writes. A configuration using 'Anthropic-Beta' (valid, and the casing used in Anthropic's own docs) is not recognised as the same field, so main emits two keys for one field name and the caller's flags survive only if the transport happens to fold them — an outcome that depends on the SDK rather than on this function.

No existing test covers a caller who sets this header. On main, the only merge case in tests/claude-headers.test.ts is :44-51, which passes { 'X-Custom': 'value' }; in tests/integration.test.ts the resolveModel(…) merge case is :128-143, which passes X-Custom and Authorization. Both keep passing after the change described below.

Measured

Both module versions (base copy from git show c2ad42e3:…) are imported into one Node process (node --experimental-strip-types), and every row also counts how many keys of the result fold to anthropic-beta case-insensitively. "caller flag kept" is derived from the symptom (is the requested flag still in the list?) rather than from the implementation.

case (model) caller sends main returns with a list merge changed
control, no headers at all (claude-3-5-sonnet-20241022) — anthropic-beta: <defaults> identical no
control, non-Claude model (gpt-4) anthropic-beta: context-1m-2025-04-11 anthropic-beta: context-1m-2025-04-11 identical no
control, unrelated custom headers (claude-3-haiku) X-Custom, Authorization both kept + defaults identical no
control, caller strips an auto flag (claude-opus-4-1) anthropic-beta: token-efficient-tools-2025-02-19 <defaults> <defaults> — unchanged, see "Suggested fix" no
caller requests the 1M-context beta (claude-sonnet-4-5) anthropic-beta: context-1m-2025-04-11 <defaults> — flag gone <defaults>,context-1m-2025-04-11 yes
caller writes the field in another case (claude-sonnet-4-5) Anthropic-Beta: prompt-caching-scope-2026-01-05 2 keys for one field name 1 key, <defaults>,prompt-caching-scope-2026-01-05 yes
edge: padded list with empty entries (claude-3-5-sonnet-20241022) anthropic-beta: ' flag-a , ,, flag-b ' <defaults> <defaults>,flag-a,flag-b yes
edge: empty string value (claude-3-5-sonnet-20241022) anthropic-beta: '' <defaults> <defaults> no

<defaults> is fine-grained-tool-streaming-2025-05-14,token-efficient-tools-2025-02-19, and getClaudeHeaders()'s return value is byte-identical before and after, so the five rows marked "no" — including both non-Claude paths — stay exactly as they are today. 3 of the 8 rows change; every changed row is a caller-supplied flag surviving instead of vanishing.

Expected vs. Actual

Expected: setting model.headers['anthropic-beta'] in the agent configuration adds a beta flag to the request, as documented at model-provider/README.md:105. Actual: for a Claude model the configured value is dropped and replaced by the built-in two flags, so the beta cannot be requested at all; with a different field-name casing, one field name is emitted twice.

Suggested fix

Treat the automatic block as a merge target rather than an override: copy the caller's object, find the entry whose key folds to anthropic-beta (reusing the caller's own casing so exactly one field name is sent), split it on commas, trim, drop empties and duplicates, and append whatever is not already in the defaults.

typescript
const betaKey =
  Object.keys(headers).find((key) => key.toLowerCase() === ANTHROPIC_BETA_HEADER) ??
  ANTHROPIC_BETA_HEADER;

Two alternatives, both defensible, both worse here:

  • let the caller's value win outright — that is the documented "merge" taken literally, but it silently turns off tool streaming for anyone who sets the header for an unrelated reason, and unlike the union it is not reversible from the log;
  • leave it as is and document the override — this only moves the problem, because the union is expressible in the header's own format.

A limitation worth stating rather than hiding: this makes the defaults not removable. A caller who deliberately wants to drop token-efficient-tools-2025-02-19 still cannot (the 4th measured row is unchanged). Disabling an automatic flag is a different feature — it needs an opt-out in the options, not a header value — and is out of scope here; the bug being reported is that a flag can be added but never kept.

The same replace-instead-of-merge shape exists in the sibling model-provider/src/azure-claude-params.ts, which overwrites an anthropic_beta entry inside model.params. It is not touched here: that path is only entered when the model id matches a hardcoded list in src/constants.ts, and src/constants.ts is already being edited by an open pull request, so changing that file's consumers now would collide — see below.

Additional context

  • Screened against the 136 open pull requests (re-polled at 2026-09-23T19:51Z, and the set is identical to the one screened at 19:36Z): none touches model-provider/src/claude-headers.ts or multimodal/tarko/model-provider/tests/claude-headers.test.ts, so this change and its test file overlap with nothing.
  • Deliberately avoided, because other open PRs own them: #1929 touches model-provider/src/constants.ts, src/types.ts, README.md and tests/integration.test.ts; #1850 touches README.md, tests/integration.test.ts, tests/llm-client.test.ts, tests/model-resolver.test.ts; #1963 and #1850 touch the websites/tarko/docs/{en,zh}/guide/basic/model-provider.mdx guides. Hence: no edit to README.md:105 (it already states the intended behaviour, and the code is what is wrong), no edit to the types.ts:42-44 doc comment, and the new tests go into tests/claude-headers.test.ts, the one file in this package that covers this function and that no open PR touches.
  • model-provider's suite on main: 5 files, 43 tests passing, and tsc --noEmit -p tsconfig.json exits 0. With the new cases but the base source: 4 failed | 46 passed. With the fix: 50 passed, tsc --noEmit still exit 0, run under CI=true so nothing could rewrite a snapshot.
  • The new cases also pin two things the fix relies on and that nothing asserts today: the caller's object is not mutated (expect(result).not.toBe(headers), so the config object handed to resolveModel() stays as written), and the default list is unchanged for a caller who provides nothing usable.
  • secretlint clean on both changed files, and the diff is prettier-clean under this subtree's real configuration (multimodal/.editorconfig:10 sets max_line_length= 100, which is what prettier picks up as printWidth here — neither .prettierrc.mjs nor multimodal/.prettierrc.yml sets printWidth).
  • CI that reports on a multimodal/** diff is Agent TARS Build (.github/workflows/agent_tars_test.yml, paths: multimodal/**, ubuntu-latest); the root CI Test, Typecheck workflow is paths-ignore: multimodal/** and does not run for this change.

Source: bytedance/UI-TARS-desktop