# Bedrock structured outputs now work on Claude Sonnet 5 — dropping forced tool use turned thinking on

> Amazon Bedrock now accepts outputConfig.textFormat on Claude Sonnet 5. Replacing forced tool use with it fixed the JSON shape, but needed thinking disabled.

- Source: https://oharu121.com/blog/bedrock-structured-outputs-sonnet-5-textformat-thinking/
- Published: 2026-10-23T08:26:47+09:00
- Tags: Amazon Bedrock, Claude, Structured Outputs

---
## Introduction

Earlier this month, Amazon Bedrock refused structured outputs on Claude Sonnet 5, so a translation pipeline I work on kept its JSON in shape with [a prompt rule and an error-feedback retry](/blog/bedrock-structured-outputs-claude-sonnet-5-error-feedback/). On 2026-10-20, AWS announced structured outputs for all Claude models on Bedrock, and I wanted to know whether that workaround could go.

It could. With the prompt rule removed, forced tool use broke 5 of 20 calls on a real batch, and `outputConfig.textFormat` broke none. **The switch only kept the old speed with thinking turned off**, because forced tool use had been keeping Sonnet 5 from thinking at all, and without it one 143-segment batch went from 47 seconds to 731.

Bedrock now enforces the schema while the model generates, and the request no longer depends on forced tool use, which the 5.5 models refuse.

## What Bedrock accepts on Claude Sonnet 5 now

**Every Claude model I could call on Bedrock now takes a JSON Schema for its answer.** Claude Code sent one small request per model to the `global.anthropic.*` inference profiles in `ap-northeast-1`. It went through both runtime APIs: Converse, Bedrock's model-agnostic chat API, and InvokeModel, which carries Anthropic's own request body.

| Model | Converse `outputConfig.textFormat` | InvokeModel `output_config.format` |
| --- | --- | --- |
| Sonnet 4.5, 4.6, 5, 5.5 | Accepted | Accepted |
| Opus 4.5, 4.6, 4.7, 4.8, 5, 5.5 | Accepted | Accepted |
| Haiku 4.5, 5.5 | Accepted | Accepted |
| Fable 5, 5.1 | Not testable | Not testable |

The prompt asked for prose, an enum value outside the schema, and an extra key, and every accepted response still matched the schema. Fable could not be used because of an account setting, so I did not check whether it supports structured outputs. The Sonnet 5 model card now lists Structured outputs under Supported.

A deliberately broken schema, `{"type": "bogus"}`, was rejected, so Bedrock reads the field instead of dropping it:

```text
ValidationException: The json schema definition at outputConfig.textFormat.structure.jsonSchema is invalid.
```

Converse takes the schema serialised to a JSON string with `json.dumps()`, while InvokeModel takes the Python dict itself, so **the two request shapes are not interchangeable**:

```python title="Converse request"
client.converse(
    modelId="global.anthropic.claude-sonnet-5",
    messages=messages,
    outputConfig={"textFormat": {
        "type": "json_schema",
        "structure": {"jsonSchema": {
            "schema": json.dumps(schema),  # a string; InvokeModel takes the dict
            "name": "translations",
        }},
    }},
)
```

```python title="InvokeModel request"
client.invoke_model(
    modelId="global.anthropic.claude-sonnet-5",
    body=json.dumps({
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": max_tokens,
        "messages": anthropic_messages,
        "output_config": {"format": {
            "type": "json_schema",
            "schema": schema,  # a dict; Converse takes a JSON string
        }},
    }),
)
```

## toolChoice, strict, and textFormat each guarantee a different thing

`toolChoice` controls **how the model picks from the `tools` list**: let it decide (`auto`), force some tool (`any`), or force a named one (`tool`). `strict: true` on a tool definition makes that tool's arguments match its schema when it is called. `outputConfig.textFormat` makes the reply itself match a schema, with no tool involved.

*Figure — ThreeKnobs: Only toolChoice can force a call. strict and textFormat make JSON match a schema: strict for a tool's arguments, textFormat for the reply.*

`strict` and `textFormat` are the two halves of structured outputs. Both use constrained decoding: Bedrock compiles the schema into a grammar and the model can only sample tokens the grammar allows. `toolChoice` is older and is not part of structured outputs.

*Figure — ConstrainedDecoding: Bedrock compiles the schema into a grammar that masks every token it does not allow. The tokens are illustrative.*

### Are forced tool use and structured outputs mutually exclusive?

No. I had assumed structured outputs was implemented as a tool, so it only worked with forced tool use.

Structured outputs is not a tool. It restricts which tokens the model can pick, in two ways. `strict` is an attribute on a tool definition and constrains that tool's arguments when the model calls it. `textFormat` sits on the request and constrains the whole reply.

That assumption describes the workaround the earlier article was built on. Define a tool whose input schema is the answer, force it with `toolChoice: {"tool": {"name": ...}}`, and read the arguments. **Forcing guarantees the call and never checks its contents**, which is how the keys came back as `text`.

`strict` fills the gap forced tool use leaves. Forcing guarantees the model calls the fake tool, and `strict` guarantees the `input` it fills in matches the tool's schema. In other words, the result does not come from running the fake tool. It is the `input` the model put into it, already JSON that matches the schema.

*Figure — FakeTool: Both rows offer a tool and let the model fill in its arguments. A normal tool is then run. The fake tool is not, and the arguments themselves are the answer.*

Sonnet 5 accepted a request carrying both `strict` and forced tool use, and every result matched the schema. `textFormat` is separate from tools altogether, so it needs **no tool at all** and nothing to force.

### What auto, any, and tool mean

`toolChoice` decides how the model uses the `tools` list:

1. `{"auto": {}}`: the model decides which tool to call, or answers in text with no tool at all. It is also what the API does when `toolChoice` is left out.

   ```python
   tool_config = {
       "tools": [get_weather, get_time],
       "toolChoice": {"auto": {}},  # may call either tool, or answer in text only
   }
   ```

2. `{"any": {}}`: the model must call at least one tool from the list, and generates no text.

   ```python
   tool_config = {
       "tools": [get_weather, get_time],
       "toolChoice": {"any": {}},  # must call a tool, but the model picks which
   }
   ```

3. `{"tool": {"name": ...}}`: the model must call the named tool.

   ```python
   tool_config = {
       "tools": [get_weather, get_time],
       "toolChoice": {"tool": {"name": "get_weather"}},  # must call get_weather
   }
   ```

The last two are forced tool use: the model has to use a tool and cannot answer in text.

In the small probe, Opus 4.7, Opus 4.8, and Haiku 5.5 answered in text with `stopReason: end_turn` and made no tool call. With `strict` and auto, **the schema holds only if a call happens**.

### Is strict the same thing as textFormat?

**Same engine, different target.** AWS describes them as two mechanisms that "can be used independently or together in the same request".

*Figure — StrictVsTextFormat: strict puts the schema on a fake tool that never runs, and the answer comes back as the tool_use input. textFormat puts it on the request, and the answer is the reply itself.*

| | `strict: true` | `outputConfig.textFormat` |
| --- | --- | --- |
| Where it goes | Inside a tool definition | Top level of the request |
| What it constrains | The JSON the model puts in the fake tool's input | The model's output when it answers in text |
| When it applies | When the model chooses to call that tool | When the model chooses not to call a tool and answers in text (every response, if no tools are offered) |

Sending both in one request works, and each keeps to its own output. I sent 30 requests to Sonnet 5 with a `strict` tool, `toolChoice: auto`, and `textFormat` all set. Every reply came back as either a tool call or text, never both.

With auto, though, the model mostly called the tool, including 4 of the 10 runs told not to use any tool, so **`textFormat` applies only when the model decides not to call the tool**.

`strict` and `textFormat` take two separate paths. When the model calls the tool, `strict` applies; when it answers in text instead, `textFormat` applies. Either way, the result matches a schema. All 24 tool inputs and all 6 texts matched their own schema.

## Three ways to ask for the same JSON on a real batch

I tried three combinations: forced tool use + `strict`, `strict` + auto, and `textFormat`. **`textFormat` was the only request that held on both Sonnet 5 and Sonnet 5.5 without leaving a decision to the model.**

The test batch had 63 segments, and the prompt rule naming `text` as a forbidden key was removed so that a shape error had a chance to appear. Each request ran 20 times on Sonnet 5 and 10 times on Sonnet 5.5.

*Figure — RequestShapes: S1 forces the tool, A1 lets the model decide whether to call it, and T1 uses no tool at all. Only T1 never failed.*

| Request | Model | Valid | Median time |
| --- | --- | --- | --- |
| Forced tool use, prompt rule kept | Sonnet 5 | 20 of 20 | 25 s |
| Forced tool use, prompt rule removed | Sonnet 5 | 15 of 20 | 25 s |
| S1: forced tool use + `strict` | Sonnet 5 | 20 of 20 | 24 s |
| A1: `strict` + auto | Sonnet 5 | 19 of 20 | 24 s |
| T1: `textFormat` | Sonnet 5 | 20 of 20 | 24 s |
| S1: forced tool use + `strict` | Sonnet 5.5 | Rejected with a 400 | — |
| A1: `strict` + auto | Sonnet 5.5 | 10 of 10 | 36 s |
| T1: `textFormat` | Sonnet 5.5 | 10 of 10 | 36 s |

The five failures without the rule all named every key `text`, the same error the earlier article chased. S1 fixes that with the smallest change, but **Sonnet 5.5 and Opus 5.5 refuse forced tool use outright**:

```text
tool_choice: type "tool" and "any" are not supported for this model.
```

A1's one failure called the tool with an empty `translations` array after 75 output tokens and two seconds. An empty array satisfies the schema, so **`strict` let it through**. I chose T1 in the end. Under auto, A1 can also skip the call entirely, and T1 has no tool to skip.

### What structured outputs still leaves to the code

The schema guarantee has edges, and the adapter still checks each one:

1. **A response cut off at `max_tokens`, or a refusal, may not match the schema.** Anthropic's structured-outputs page says so for both. Converse has no `refusal` stop reason, so the adapter reads the reply only when `stopReason` is `end_turn`.
2. **The item count and the ids are not constrained.** `minItems` (how many items an array must have) accepts only 0 or 1, so it cannot require 63, which is how A1's empty array passed. The comparison against the input ids stays.
3. **Every object needs `"additionalProperties": false`.** Bedrock returns a 400 for any other value.

## Leaving forced tool use turned adaptive thinking on

**Forced tool use had been keeping Sonnet 5 from thinking, and `textFormat` let it think again.** Adaptive thinking is Claude's default mode on Sonnet 5: the model decides per request whether to reason before answering and for how long, and the reasoning tokens count against `maxTokens`.

Translating a whole document with `textFormat`, most batches took as long as before, but three slowed down sharply. The slowest was attempted six times, because five of those ran past the 120-second `read_timeout`. botocore, the AWS SDK under boto3, retried each one without raising, so **every timed-out attempt was billed and none was logged as a failure**.

*Figure — ThinkingLatency: The 143- and 80-segment batches under forced tool use, textFormat with thinking on, and textFormat with thinking off. With thinking on, the 143-segment batch ran past the 120-second timeout five times before one attempt finished.*

Running the slow batches three more times each, with a 300-second timeout and no retries, separated the cause:

| Request | Time | Stop reason | Output tokens | Runs with reasoning |
| --- | --- | --- | --- | --- |
| Forced tool use | 42–47 s | 6 of 6 `tool_use` | 6,767–7,304 | 0 of 9 |
| `textFormat`, thinking on | 45–138 s | 2 of 6 `max_tokens` | 6,854–16,384 | 5 of 9 |
| `textFormat`, thinking off | 41–46 s | 6 of 6 `end_turn` | 6,598–6,871 | 0 of 9 |

Time, stop reason, and output tokens cover the two slowest batches (six runs). The last column covers all nine.

The JSON itself was the same length in every mode, about 13,000 characters for one batch, so **the extra tokens were reasoning**. The 143-segment batch was just as slow on a second full run, long after the grammar had been compiled and cached, so compilation was not the delay.

Anthropic's thinking troubleshooting page documents the cause: "A forced `tool_choice` (`{"type": "any"}` or a named tool) also returns no `thinking` block: the response starts with the tool call." Under forced tool use, the pipeline had been translating without thinking all along. **So after switching to `textFormat`, thinking has to be turned off to avoid the timeouts and the extra tokens.**

## Turning thinking off, and the models that refuse it

Sending `thinking: {"type": "disabled"}` brought `textFormat` **back to the forced-tool-use numbers**. Converse passes Anthropic-specific fields through `additionalModelRequestFields`:

```python title="Converse request" ins={3}
client.converse(
    # …
    additionalModelRequestFields={"thinking": {"type": "disabled"}},
    outputConfig={"textFormat": {"type": "json_schema", "structure": {"jsonSchema": {
        "schema": json.dumps(schema), "name": "translations",
    }}}},
)
```

On 23 batches, **every response matched the schema and every id came back**. The longest call took 45 seconds, and none was retried. Translation quality on the pipeline's evaluation suite stayed within the run-to-run spread of the forced-tool-use version.

Not every model accepts that setting. Anthropic's per-model table:

| Model | `thinking: {"type": "disabled"}` |
| --- | --- |
| Sonnet 5 | Accepted |
| Opus 5, Haiku 5.5 | Accepted at effort `high` or below |
| Sonnet 5.5 | 400; send `{"type": "between_tools"}` instead |
| Opus 5.5, Fable 5, Fable 5.1 | 400; thinking is always on |

`disabled` turns thinking off entirely. `between_tools`, accepted only by Sonnet 5.5, turns off the thinking before the reply, but the short updates the model writes between tool calls still come back as thinking blocks. By the documentation, a single call with no tools, like this pipeline's, should behave the same under either.

The pipeline reads its model ID from SSM Parameter Store at runtime, so it can be switched to Sonnet 5.5 without a redeploy, and **every batch after that would fail with a 400**. I made the adapter refuse a model ID that has not been verified with thinking off, before it calls Bedrock:

```python title="bedrock adapter"
THINKING_DISABLED_MODELS = frozenset({"claude-sonnet-5"})

def __init__(self, *, model_id: str) -> None:  # other arguments elided
    if model_id.rsplit("anthropic.", 1)[-1] not in THINKING_DISABLED_MODELS:
        raise ConfigurationError(
            f"model not verified to run with thinking off: {model_id}"
        )
```

The error surfaces with the model ID in it, instead of as a stream of Bedrock 400s, and adding a model means measuring it first.

## Summary

| Problem | What handles it |
| --- | --- |
| Forced tool use guarantees a call, not its contents | `outputConfig.textFormat`, with no tool: 20 of 20 without the prompt rule |
| Sonnet 5.5 and Opus 5.5 refuse forced tool use | The same `textFormat` request: 10 of 10 on Sonnet 5.5 |
| Leaving forced tool use turns adaptive thinking back on | `thinking: {"type": "disabled"}`, restoring the forced-tool-use latency and token count |
| `max_tokens` cuts, refusals, item count, and ids | Read only `end_turn`, and keep the id check |
| A model switch to one that rejects `disabled` | An allow-list that fails with the model ID before calling Bedrock |

On Bedrock, Claude Sonnet 5 now takes a JSON Schema for its reply. Moving off forced tool use also changes whether the model thinks, so **measure latency and output tokens on your heaviest batch before switching**, not only the shape of the JSON.

## References

- [AWS What's New: Amazon Bedrock supports structured outputs for all Anthropic Claude models](https://aws.amazon.com/jp/about-aws/whats-new/2026/10/claude-structured-outputs-bedrock/)
- [Amazon Bedrock: Get validated JSON results from models (structured outputs)](https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html)
- [Amazon Bedrock model card: Claude Sonnet 5](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-5.html)
- [Amazon Bedrock API: ToolChoice, defining auto, any, and tool](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolChoice.html)
- [Claude API: Structured outputs, including what is not guaranteed](https://platform.claude.com/docs/en/build-with-claude/structured-outputs)
- [Claude API: Troubleshooting thinking, with the per-model disabled-thinking table](https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting)
- [Claude API: Define tools, including forced tool use restrictions](https://platform.claude.com/docs/en/agents-and-tools/tool-use/define-tools)
