model-provider: a configured `anthropic_beta` list in `model.params` is replaced instead of merged for Azure Claude models

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

Description

addAzureClaudeParamsIfNeeded() is documented as adding the Azure Claude beta flag, but it spreads the automatic value over whatever anthropic_beta list the caller already put in model.params, so the caller's flags are discarded rather than added to. This is the model.params twin of the model.headers bug in #2015, and the follow-up I pointed at in the scope note of #2016 (and its correction comment): the same replace-instead-of-merge shape, in the other file that has one.

On main (c2ad42e3), multimodal/tarko/model-provider/src/azure-claude-params.ts:15-29:

typescript
export function addAzureClaudeParamsIfNeeded(
  modelId: string,
  provider: string,
  existingParams?: Record<string, any>,
): Record<string, any> | undefined {
  // Only apply to azure-openai provider with gcp-claude4-sonnet model
  if (provider === 'azure-openai' && modelId === 'gcp-claude4-sonnet') {
    return {
      ...existingParams,
      anthropic_beta: ['fine-grained-tool-streaming-2025-05-14'],
    };
  }

  return existingParams;
}

...existingParams at :23 copies the caller's object first and :24 then overwrites the anthropic_beta key with a one-element list of its own. Both sides write the same key, so whenever a configuration supplies anthropic_beta, exactly one of the two values survives — the automatic one.

Why this is a bug and not the intended precedence

  1. The function's own contract says "adds". Its JSDoc reads "Automatically adds anthropic_beta parameters for Azure OpenAI provider with Claude models" (:7) — the verb is add, not override — the name is addAzureClaudeParamsIfNeeded, and the parameter is documented as "Existing experimental parameters" that the result is supposed to update (:12-13). Replacing one entry of the object it was handed is not adding.
  2. The value is a list, so there is nothing to choose between. anthropic_beta is a list of beta flags — that is how this very line builds it (['fine-grained-tool-streaming-2025-05-14']). A caller's ['context-management-…'] is another element of the same list, not a competing value for one slot, so the merge costs nothing.
  3. model.params is a supported, user-facing passthrough. multimodal/tarko/model-provider/src/types.ts:67-71 documents it as "Experimental parameters passed directly through request body / @warning Use with caution - these parameters bypass validation" on the params?: Record<string, any> field of AgentModel, i.e. the option exists precisely so callers can write body fields this package knows nothing about — including this one.

Nothing pins the current behaviour either: the package's own merge test (multimodal/tarko/model-provider/tests/azure-claude-params.test.ts:17-24) feeds in an unrelated key ({ customParam: 'value' }), and tests/model-resolver.test.ts:145-166 resolves an AgentModel with no params at all, so neither of them ever has two writers for anthropic_beta.

Reachability, hop by hop

  1. multimodal/tarko/agent-interface/src/agent-options.ts:9 imports AgentModel from @tarko/model-provider/types and :69 exposes it as model?: AgentModel on the public AgentModelOptions — so model.params is a documented agent option, not an internal field.
  2. multimodal/tarko/agent/src/agent/agent.ts:94 (this.currentModel = resolveModel(options.model)) and :513 pass that object into model-provider.
  3. multimodal/tarko/model-provider/src/model-resolver.ts:78 (const params = addAzureClaudeParamsIfNeeded(model, provider, agentModel?.params)) calls the function under report and :87 puts its return value on the resolved model.
  4. multimodal/tarko/model-provider/src/llm-client.ts:31 destructures params off the resolved model, :69-70 spreads it into requestPayload with the comment "Merge experimental params directly into request body", and :77-80 hands that payload to client.chat.completions.create(...).

So a flag a user writes in model.params.anthropic_beta reaches this function, is dropped there, and never appears in the HTTP body — the one place it was headed.

Reproduction

typescript
import { addAzureClaudeParamsIfNeeded } from '../src/azure-claude-params';

const params = addAzureClaudeParamsIfNeeded('gcp-claude4-sonnet', 'azure-openai', {
  anthropic_beta: ['context-management-2025-06-27'],
});

console.log(params?.anthropic_beta);
// expected: ['fine-grained-tool-streaming-2025-05-14', 'context-management-2025-06-27']
// actual on main: ['fine-grained-tool-streaming-2025-05-14']

Measured by importing the main copy and a candidate copy of the module into one Node process (node --experimental-strip-types) and diffing their results over 9 inputs: 4 rows change, 5 are byte-identical. Every changed row is a caller-supplied flag surviving instead of vanishing; the 5 identical rows are the controls (no params, unrelated param, caller passing only the automatic flag, caller passing '', and the non-matching-model path).

Scope

The trigger itself is deliberately not part of this: it matches one exact model-id literal at :21 (modelId === 'gcp-claude4-sonnet'), while the repo's own azure-openai examples deploy Claude under different ids (multimodal/tarko/agent/examples/model-providers/azure-openai.ts:15-17 uses aws_sdk_claude37_sonnet, and multimodal/pdk.config.ts:14-15 pairs provider: 'azure-openai' with model: 'aws_sdk_claude37_sonnet'), so for those the function returns existingParams untouched and the beta is not added at all. Deciding which Azure-hosted Claude deployments need the automatic flag is a product call about the proxy, not a defect fix, and widening the match would start sending a beta to deployments that may not accept it. This issue is only about the case where the function already fires: it should add, not replace.

I would like to work on this.

Source: bytedance/UI-TARS-desktop