model-provider: a configured `anthropic-beta` header is replaced instead of merged, so no Claude beta flag can be requested
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
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.
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.tsormultimodal/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.mdandtests/integration.test.ts; #1850 touchesREADME.md,tests/integration.test.ts,tests/llm-client.test.ts,tests/model-resolver.test.ts; #1963 and #1850 touch thewebsites/tarko/docs/{en,zh}/guide/basic/model-provider.mdxguides. Hence: no edit toREADME.md:105(it already states the intended behaviour, and the code is what is wrong), no edit to thetypes.ts:42-44doc comment, and the new tests go intotests/claude-headers.test.ts, the one file in this package that covers this function and that no open PR touches. model-provider's suite onmain: 5 files, 43 tests passing, andtsc --noEmit -p tsconfig.jsonexits 0. With the new cases but the base source: 4 failed | 46 passed. With the fix: 50 passed,tsc --noEmitstill exit 0, run underCI=trueso 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 toresolveModel()stays as written), and the default list is unchanged for a caller who provides nothing usable. secretlintclean on both changed files, and the diff is prettier-clean under this subtree's real configuration (multimodal/.editorconfig:10setsmax_line_length= 100, which is what prettier picks up asprintWidthhere — neither.prettierrc.mjsnormultimodal/.prettierrc.ymlsetsprintWidth).- CI that reports on a
multimodal/**diff isAgent TARS Build(.github/workflows/agent_tars_test.yml,paths: multimodal/**,ubuntu-latest); the rootCI Test, Typecheckworkflow ispaths-ignore: multimodal/**and does not run for this change.
Source: bytedance/UI-TARS-desktop