Repository navigation
tool declaration fields are dropped on the outbound adapters #5210
Description
Client or integration
Claude Code
Provider or upstream service
anthropic, google
OpenCodex version
2.59.0 / commit 134c92a01b120162f00c7275189cc47858720379
Endpoint or capability
tool calls
Current behaviour
Three fields a caller sets on a tool declaration do not reach the upstream. All three are lost at the outbound adapter, after the value has already been parsed and carried internally.
| # | route | field | forwarded |
|---|---|---|---|
| 1 | Messages to Messages | tools[*].strict |
tool without strict |
| 2 | Messages to Messages | tools[*].allowed_callers |
tool without allowed_callers |
| 3 | Messages to Gemini generateContent |
tools[*].strict |
no toolConfig mode expressing it |
Cases 1 and 2 are the same function. src/adapters/anthropic.ts:850 rebuilds every tool from exactly three fields:
const converted = tools.map(t => ({
name: toolNames.toWire(namespacedToolName(t.namespace, t.name)),
description: t.description,
input_schema: normalizeAnthropicInputSchema(t.parameters),
}));For case 1 the value is present and deliberately kept. src/claude/inbound-content-options.ts:37 reads it with a comment stating the intent:
// Anthropic opts into strict tool use explicitly, while Responses reads an
// omitted strict as permission to normalize the schema into strict mode. That
// turns an optional input_schema parameter into a required one and breaks the
// call, so carry the source intent instead of the destination default. A
// non-boolean value is not a valid Anthropic opt-in and must not become one.
strict: typeof raw.strict === "boolean" ? raw.strict : false,src/types/tools.ts:5 holds it, and the OpenAI Chat adapter forwards it at
src/adapters/openai-chat/tool-schema.ts:438:
...(t.strict !== undefined ? { strict: t.strict } : {}),So the field survives the parse, reaches the adapter, is forwarded to one target, and is dropped for Anthropic, which is the target that defines it.
For case 2 there is no carrier at all. Across the whole tree the identifier occurs once:
grep -rn "allowed_callers\|allowedCallers" src/
src/claude/compatibility.ts:104: if (nonDirectCaller(tool.allowed_callers)) codes.add("caller_mode");
That line only raises a diagnostic code. src/types/tools.ts:1-22 has no member for it and the Anthropic inbound parse at src/claude/inbound-content-options.ts:26-39 does not read it.
For case 3 the mode exists and is plumbed end to end, but is never reached from a caller's strict declaration. src/adapters/google.ts:464 copies three fields:
functionDeclarations: tools.map(t => ({
name: namespacedToolName(t.namespace, t.name),
description: t.description,
parameters: t.parameters,
})),and src/adapters/google.ts:485 selects the mode from tool choice alone, never producing VALIDATED. The word strict occurs once in that whole file and it is an unrelated comment:
grep -c "strict" src/adapters/google.ts
1
VALIDATED is reachable at src/adapters/google.ts:961, but selected by model name rather than by the caller's request:
// Claude-on-Antigravity forces VALIDATED function calling (the real client always sets it).
if (/claude/i.test(wireModelId)) {and src/adapters/google-wire-compiler.ts:167 already accepts it on the wire.
In every case the request succeeds, so the caller has no way to learn the constraint is not in force.
Expected behaviour
Case 1. toolsToAnthropicFormat emits strict when the internal tool carries it, so an explicit strict: true reaches the upstream unchanged. This matches the expectation already accepted in #3922, which established that an explicit strict: true should remain true through translation and fixed the inbound half.
Case 2. A caller-supplied allowed_callers reaches the upstream on a route whose target is Anthropic, since the field belongs to the same tool object being rebuilt. If carrying it is not intended, the request should not be dispatched as though the restriction were in effect.
Case 3. When a caller declares strict tools and the route targets Gemini, the adapter should be able to select functionCallingConfig.mode: "VALIDATED".
Native passthrough is unaffected in all three cases and should stay unaffected.
Minimal redacted request or reproduction
Cases 1 and 2, one request covers both:
curl http://127.0.0.1:10100/v1/messages \
-H 'content-type: application/json' \
-H 'x-api-key: REDACTED' \
-H 'anthropic-version: 2023-06-01' \
-d '{
"model": "REDACTED_PROVIDER/REDACTED_MODEL",
"max_tokens": 64,
"messages": [{"role": "user", "content": "Call the tool."}],
"tools": [
{
"name": "tool_a",
"description": "Controlled tool.",
"input_schema": {"type": "object", "properties": {}},
"strict": true,
"allowed_callers": ["code_execution_20260120"]
}
]
}'
Route it to an Anthropic-protocol provider so the translated path is used rather than native passthrough, then inspect the request the proxy sends upstream.
Case 3, the same tool declaration routed to a Google provider.Actual response or error
Cases 1 and 2, the forwarded tool entry as built by the lines cited above:
"tools": [
{
"name": "tool_a",
"description": "Controlled tool.",
"input_schema": {"type": "object", "properties": {}}
}
]
Both `strict` and `allowed_callers` are absent.
Case 3, the forwarded request carries `functionDeclarations` with name, description and parameters, and no `toolConfig` expressing schema adherence.Upstream documentation
Case 1, https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use.md
"Setting strict: true on a tool definition guarantees Claude's tool inputs match your JSON Schema by constraining the model's token sampling to schema-valid outputs."
The field is $.tools[*].strict on the Messages request.
Case 2, https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-reference.md
The field is $.tools[*].allowed_callers[*] on the Messages request, documented as an array accepting a combination of caller kinds.
Case 3, https://ai.google.dev/gemini-api/docs/generate-content/function-calling.md.txt
"The model is constrained to predict either function calls or natural language, and ensures function schema adherence."
The construct is $.toolConfig.functionCallingConfig.mode.
Suggested mapping or implementation notes
Case 1. Add the field to the object built in toolsToAnthropicFormat, in the same conditional shape the OpenAI Chat adapter already uses:
...(t.strict !== undefined ? { strict: t.strict } : {}),The inbound sets false rather than leaving the field absent when the caller omits it, so emitting on !== undefined would add strict: false to requests that previously had no such key. If byte compatibility for the common case matters, gate on t.strict === true instead so only an explicit caller opt-in changes the wire.
Case 2 needs a carrier before it needs a serializer. Either add an optional member to OcxTool in src/types/tools.ts, populate it in the Anthropic tool parse, and emit it from toolsToAnthropicFormat when present, which leaves every other adapter unchanged because they would ignore the new member. Or, if carrying it is out of scope, treat a non-direct allowed_callers as a route-level incompatibility and surface it rather than dispatching. The detection already exists at src/claude/compatibility.ts:104, so only the default would change.
Case 3. toolChoiceToGeminiToolConfig already receives the whole parsed object, so it can see whether any declared tool carries strict. A minimal shape selects VALIDATED when the caller declared strict tools and the tool choice does not already demand a different mode, leaving NONE and the specific-tool allowedFunctionNames cases untouched. The precedence between ANY and VALIDATED is a judgement call, and the Antigravity branch at src/adapters/google.ts:963 is a reasonable precedent for how the two interact.
Additional context and attachments
- Claude Code optional tool parameters become strict on OpenAI Responses routes #3922 established for the Responses direction that a caller's
strictintent must survive translation. The inbound fix from that issue is the code quoted above. The Anthropic and Google outbounds were not covered. src/adapters/ollama-native.ts:471declines to forwardstrictand documents the reason,
that/api/chathas no documented strict field. Anthropic and Gemini both document a
counterpart, and neither adapter carries an equivalent note, which suggests an omission rather
than a decision.src/claude/compatibility.tsclassifiesstrict_toolsandcaller_modeas unsupported on
translated routes and can reject with 400, but only whenclaudeCode.compatibilityis set.
src/types/config.ts:15documents the default as "unset keeps legacy behavior" and
src/server/claude-messages.ts:738skips the whole check in that case. So the gate does not
change the default behaviour here, and rejecting a request is not equivalent to carrying a
field the target supports.
Checks
- I searched existing provider and compatibility issues.
- The request and response were redacted.
- The expected behaviour is based on an upstream specification or a concrete client requirement.
Activity
Ingwannu commentedon Sep 20, 2026
Confirmed against current
dev(2a2c01a7). This is a real silent-policy-loss boundary, not only a metadata parity request.OcxTool.strictsurvives both Chat and Messages parsing, buttoolsToAnthropicFormat()reconstructs the outbound object without it.allowed_callershas no internal carrier; the compatibility probe can observe the raw field, while the dispatched tool declaration cannot preserve or reject it.VALIDATED.Please keep the three target contracts explicit rather than treating them as one generic spread:
allowed_callersmust either reach an Anthropic-capable destination byte-for-byte or fail before dispatch with a stable unsupported-capability error. Silently stripping an execution restriction is not acceptable.VALIDATEDneeds a destination capability decision and negative coverage for targets/models that do not accept it; do not globally turn every strict declaration into a wire mode.Native passthrough must remain byte-preserving, translated routes must not infer restrictions the caller omitted, and the tests should assert the actual outbound body for each route. Keeping this open as a verified implementation issue.
fix: preserve caller constraints on the request path (#5211, #5210, #…
lidge-jun commentedon Sep 20, 2026
Fixed on
devby #5271 (41b66be6d880625055adb319aef9bce75300a42b).The four losses on this path were fixed together because they share one failure mode: the request arrives carrying an explicit constraint, the proxy drops it, and the response is a normal success. A test that asserts a successful call or a 200 sees none of them. The governing rule the branch applies is to preserve on a supported path, and to refuse or apply the operator's conversion policy on an unsupported one, rather than dropping silently and reporting success.
Tool declaration
strictandallowed_callersnow reach destinations that support them through the declaration carrier, and an unsupported destination no longer widens the declaration by omission.Closing on
dev. PRs here targetdev, so GitHub does not auto-close.