Skip to content

Commit be260b4

Browse files
EleanorWhoclaude
andauthored
feat(messages)!: add extended thinking support (#5938)
## Summary Fills four gaps in the Messages API extended thinking support: - **`_SignatureDelta` model + stream handler** — Anthropic sends `signature_delta` events at the end of each thinking block carrying a cryptographic signature. The SSE parser was silently dropping these (returning `None` for unknown delta types). Now parsed and forwarded correctly in passthrough mode. - **`AnthropicRedactedThinkingBlock` model** — When Claude redacts a thinking block, it returns `{"type": "redacted_thinking", "data": "<opaque>"}`. This block must be echoed back as-is in multi-turn conversations. Without this model, Pydantic validation fails when replaying message history containing redacted blocks. - **`budget_tokens` validation fix** — Changed `ge=1` to `ge=1024` to match [Anthropic's documented minimum](https://platform.claude.com/docs/en/build-with-claude/extended-thinking). The previous minimum was incorrect and would result in a 400 from the upstream Anthropic API. - **Translation mode error** — When `thinking.type == "enabled"` and the request routes through translation mode (Anthropic → OpenAI format), the thinking config was silently dropped. Now raises a clear 400 error explaining that extended thinking requires a native Anthropic-compatible provider. ## Breaking changes This PR contains two intentional breaking changes flagged by the `api-conformance` pre-commit hook: 1. **`budget_tokens` minimum raised from 1 to 1024** — This is a bug fix, not a behavioral change. Values between 1 and 1023 were never valid per the Anthropic API and would have been rejected upstream with `invalid_request_error`. We now reject them at the OGX layer with a Pydantic validation error instead. 2. **`AnthropicRedactedThinkingBlock` added to `AnthropicContentBlock` union** — This is an additive change to a discriminated union. Existing clients that only handle known block types (text, image, tool_use, tool_result, thinking) are unaffected — the discriminator ensures they won't accidentally match the new variant. Clients that exhaustively match all variants will need to handle or skip `redacted_thinking`. ## Test plan ```bash # Run messages unit tests (28 tests, all pass) uv run pytest tests/unit/providers/inline/messages/test_impl.py -xvs # Run full unit test suite (2275 tests pass, no regressions) uv run pytest tests/unit/ -x --tb=short --ignore=tests/unit/providers/vector_io ``` Test output: ``` tests/unit/providers/inline/messages/test_impl.py::TestSSEParsing::test_signature_delta_parsed PASSED tests/unit/providers/inline/messages/test_impl.py::TestSSEParsing::test_redacted_thinking_block_start_parsed PASSED tests/unit/providers/inline/messages/test_impl.py::TestThinkingConfig::test_budget_tokens_below_minimum_rejected PASSED tests/unit/providers/inline/messages/test_impl.py::TestThinkingConfig::test_budget_tokens_at_minimum_accepted PASSED tests/unit/providers/inline/messages/test_impl.py::TestThinkingConfig::test_budget_tokens_above_minimum_accepted PASSED tests/unit/providers/inline/messages/test_impl.py::TestThinkingConfig::test_thinking_enabled_raises_in_translation_mode PASSED tests/unit/providers/inline/messages/test_impl.py::TestThinkingConfig::test_thinking_disabled_allowed_in_translation_mode PASSED tests/unit/providers/inline/messages/test_impl.py::TestThinkingConfig::test_thinking_none_allowed_in_translation_mode PASSED tests/unit/providers/inline/messages/test_impl.py::TestRequestTranslation::test_redacted_thinking_skipped_in_assistant_message PASSED ============================== 28 passed in 0.10s ============================== ``` 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Signed-off-by: Eleanor Hu <ehu@redhat.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a50639a commit be260b4

9 files changed

Lines changed: 212 additions & 18 deletions

File tree

client-sdks/stainless/openapi.yml

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10644,15 +10644,18 @@ components:
1064410644
title: AnthropicToolResultBlock-Input
1064510645
- $ref: '#/components/schemas/AnthropicThinkingBlock'
1064610646
title: AnthropicThinkingBlock
10647+
- $ref: '#/components/schemas/AnthropicRedactedThinkingBlock'
10648+
title: AnthropicRedactedThinkingBlock
1064710649
discriminator:
1064810650
propertyName: type
1064910651
mapping:
1065010652
image: '#/components/schemas/AnthropicImageBlock'
10653+
redacted_thinking: '#/components/schemas/AnthropicRedactedThinkingBlock'
1065110654
text: '#/components/schemas/AnthropicTextBlock'
1065210655
thinking: '#/components/schemas/AnthropicThinkingBlock'
1065310656
tool_result: '#/components/schemas/AnthropicToolResultBlock-Input'
1065410657
tool_use: '#/components/schemas/AnthropicToolUseBlock'
10655-
title: AnthropicTextBlock | ... (5 variants)
10658+
title: AnthropicTextBlock | ... (6 variants)
1065610659
type: array
1065710660
title: list[AnthropicTextBlock | AnthropicImageBlock | ...]
1065810661
title: string | list[AnthropicTextBlock | AnthropicImageBlock | ...]
@@ -10692,15 +10695,18 @@ components:
1069210695
title: AnthropicToolResultBlock-Output
1069310696
- $ref: '#/components/schemas/AnthropicThinkingBlock'
1069410697
title: AnthropicThinkingBlock
10698+
- $ref: '#/components/schemas/AnthropicRedactedThinkingBlock'
10699+
title: AnthropicRedactedThinkingBlock
1069510700
discriminator:
1069610701
propertyName: type
1069710702
mapping:
1069810703
image: '#/components/schemas/AnthropicImageBlock'
10704+
redacted_thinking: '#/components/schemas/AnthropicRedactedThinkingBlock'
1069910705
text: '#/components/schemas/AnthropicTextBlock'
1070010706
thinking: '#/components/schemas/AnthropicThinkingBlock'
1070110707
tool_result: '#/components/schemas/AnthropicToolResultBlock-Output'
1070210708
tool_use: '#/components/schemas/AnthropicToolUseBlock'
10703-
title: AnthropicTextBlock | ... (5 variants)
10709+
title: AnthropicTextBlock | ... (6 variants)
1070410710
type: array
1070510711
title: Content
1070610712
description: Response content blocks.
@@ -10725,6 +10731,22 @@ components:
1072510731
- model
1072610732
title: AnthropicMessageResponse
1072710733
description: Response from POST /v1/messages (non-streaming).
10734+
AnthropicRedactedThinkingBlock:
10735+
properties:
10736+
type:
10737+
type: string
10738+
title: Type
10739+
enum:
10740+
- redacted_thinking
10741+
data:
10742+
type: string
10743+
title: Data
10744+
description: Opaque redacted thinking data.
10745+
type: object
10746+
required:
10747+
- data
10748+
title: AnthropicRedactedThinkingBlock
10749+
description: A redacted thinking content block. Must be echoed back as-is in multi-turn.
1072810750
AnthropicTextBlock:
1072910751
properties:
1073010752
type:
@@ -10814,7 +10836,7 @@ components:
1081410836
budget_tokens:
1081510837
anyOf:
1081610838
- type: integer
10817-
minimum: 1.0
10839+
minimum: 1024.0
1081810840
- type: 'null'
1081910841
description: Maximum tokens for thinking.
1082010842
type: object

docs/docs/api-anthropic-messages/conformance.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ Below is a detailed breakdown of conformance issues and missing properties for e
180180
| `requestBody.content.application/json.properties.thinking` | Type added: ['object']; Union variants removed: 3 |
181181
| `requestBody.content.application/json.properties.tool_choice` | Union variants removed: 4 |
182182
| `requestBody.content.application/json.properties.tools.items` | Union variants added: 4; Union variants removed: 16 |
183-
| `responses.200.content.application/json.properties.content.items` | Union variants added: 5; Union variants removed: 12 |
183+
| `responses.200.content.application/json.properties.content.items` | Union variants added: 6; Union variants removed: 12 |
184184
| `responses.200.content.application/json.properties.model` | Type added: ['string']; Union variants removed: 17 |
185185
| `responses.200.content.application/json.properties.role` | Default changed: assistant -&gt; None |
186186
| `responses.200.content.application/json.properties.stop_reason` | Union variants added: 1; Union variants removed: 1 |

docs/static/anthropic-coverage.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@
252252
{
253253
"property": "POST.responses.200.content.application/json.properties.content.items",
254254
"details": [
255-
"Union variants added: 5",
255+
"Union variants added: 6",
256256
"Union variants removed: 12"
257257
]
258258
},

docs/static/ogx-spec.yaml

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10197,15 +10197,18 @@ components:
1019710197
title: AnthropicToolResultBlock-Input
1019810198
- $ref: '#/components/schemas/AnthropicThinkingBlock'
1019910199
title: AnthropicThinkingBlock
10200+
- $ref: '#/components/schemas/AnthropicRedactedThinkingBlock'
10201+
title: AnthropicRedactedThinkingBlock
1020010202
discriminator:
1020110203
propertyName: type
1020210204
mapping:
1020310205
image: '#/components/schemas/AnthropicImageBlock'
10206+
redacted_thinking: '#/components/schemas/AnthropicRedactedThinkingBlock'
1020410207
text: '#/components/schemas/AnthropicTextBlock'
1020510208
thinking: '#/components/schemas/AnthropicThinkingBlock'
1020610209
tool_result: '#/components/schemas/AnthropicToolResultBlock-Input'
1020710210
tool_use: '#/components/schemas/AnthropicToolUseBlock'
10208-
title: AnthropicTextBlock | ... (5 variants)
10211+
title: AnthropicTextBlock | ... (6 variants)
1020910212
type: array
1021010213
title: list[AnthropicTextBlock | AnthropicImageBlock | ...]
1021110214
title: string | list[AnthropicTextBlock | AnthropicImageBlock | ...]
@@ -10245,15 +10248,18 @@ components:
1024510248
title: AnthropicToolResultBlock-Output
1024610249
- $ref: '#/components/schemas/AnthropicThinkingBlock'
1024710250
title: AnthropicThinkingBlock
10251+
- $ref: '#/components/schemas/AnthropicRedactedThinkingBlock'
10252+
title: AnthropicRedactedThinkingBlock
1024810253
discriminator:
1024910254
propertyName: type
1025010255
mapping:
1025110256
image: '#/components/schemas/AnthropicImageBlock'
10257+
redacted_thinking: '#/components/schemas/AnthropicRedactedThinkingBlock'
1025210258
text: '#/components/schemas/AnthropicTextBlock'
1025310259
thinking: '#/components/schemas/AnthropicThinkingBlock'
1025410260
tool_result: '#/components/schemas/AnthropicToolResultBlock-Output'
1025510261
tool_use: '#/components/schemas/AnthropicToolUseBlock'
10256-
title: AnthropicTextBlock | ... (5 variants)
10262+
title: AnthropicTextBlock | ... (6 variants)
1025710263
type: array
1025810264
title: Content
1025910265
description: Response content blocks.
@@ -10278,6 +10284,22 @@ components:
1027810284
- model
1027910285
title: AnthropicMessageResponse
1028010286
description: Response from POST /v1/messages (non-streaming).
10287+
AnthropicRedactedThinkingBlock:
10288+
properties:
10289+
type:
10290+
type: string
10291+
title: Type
10292+
enum:
10293+
- redacted_thinking
10294+
data:
10295+
type: string
10296+
title: Data
10297+
description: Opaque redacted thinking data.
10298+
type: object
10299+
required:
10300+
- data
10301+
title: AnthropicRedactedThinkingBlock
10302+
description: A redacted thinking content block. Must be echoed back as-is in multi-turn.
1028110303
AnthropicTextBlock:
1028210304
properties:
1028310305
type:
@@ -10367,7 +10389,7 @@ components:
1036710389
budget_tokens:
1036810390
anyOf:
1036910391
- type: integer
10370-
minimum: 1.0
10392+
minimum: 1024.0
1037110393
- type: 'null'
1037210394
description: Maximum tokens for thinking.
1037310395
type: object

docs/static/stainless-ogx-spec.yaml

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10644,15 +10644,18 @@ components:
1064410644
title: AnthropicToolResultBlock-Input
1064510645
- $ref: '#/components/schemas/AnthropicThinkingBlock'
1064610646
title: AnthropicThinkingBlock
10647+
- $ref: '#/components/schemas/AnthropicRedactedThinkingBlock'
10648+
title: AnthropicRedactedThinkingBlock
1064710649
discriminator:
1064810650
propertyName: type
1064910651
mapping:
1065010652
image: '#/components/schemas/AnthropicImageBlock'
10653+
redacted_thinking: '#/components/schemas/AnthropicRedactedThinkingBlock'
1065110654
text: '#/components/schemas/AnthropicTextBlock'
1065210655
thinking: '#/components/schemas/AnthropicThinkingBlock'
1065310656
tool_result: '#/components/schemas/AnthropicToolResultBlock-Input'
1065410657
tool_use: '#/components/schemas/AnthropicToolUseBlock'
10655-
title: AnthropicTextBlock | ... (5 variants)
10658+
title: AnthropicTextBlock | ... (6 variants)
1065610659
type: array
1065710660
title: list[AnthropicTextBlock | AnthropicImageBlock | ...]
1065810661
title: string | list[AnthropicTextBlock | AnthropicImageBlock | ...]
@@ -10692,15 +10695,18 @@ components:
1069210695
title: AnthropicToolResultBlock-Output
1069310696
- $ref: '#/components/schemas/AnthropicThinkingBlock'
1069410697
title: AnthropicThinkingBlock
10698+
- $ref: '#/components/schemas/AnthropicRedactedThinkingBlock'
10699+
title: AnthropicRedactedThinkingBlock
1069510700
discriminator:
1069610701
propertyName: type
1069710702
mapping:
1069810703
image: '#/components/schemas/AnthropicImageBlock'
10704+
redacted_thinking: '#/components/schemas/AnthropicRedactedThinkingBlock'
1069910705
text: '#/components/schemas/AnthropicTextBlock'
1070010706
thinking: '#/components/schemas/AnthropicThinkingBlock'
1070110707
tool_result: '#/components/schemas/AnthropicToolResultBlock-Output'
1070210708
tool_use: '#/components/schemas/AnthropicToolUseBlock'
10703-
title: AnthropicTextBlock | ... (5 variants)
10709+
title: AnthropicTextBlock | ... (6 variants)
1070410710
type: array
1070510711
title: Content
1070610712
description: Response content blocks.
@@ -10725,6 +10731,22 @@ components:
1072510731
- model
1072610732
title: AnthropicMessageResponse
1072710733
description: Response from POST /v1/messages (non-streaming).
10734+
AnthropicRedactedThinkingBlock:
10735+
properties:
10736+
type:
10737+
type: string
10738+
title: Type
10739+
enum:
10740+
- redacted_thinking
10741+
data:
10742+
type: string
10743+
title: Data
10744+
description: Opaque redacted thinking data.
10745+
type: object
10746+
required:
10747+
- data
10748+
title: AnthropicRedactedThinkingBlock
10749+
description: A redacted thinking content block. Must be echoed back as-is in multi-turn.
1072810750
AnthropicTextBlock:
1072910751
properties:
1073010752
type:
@@ -10814,7 +10836,7 @@ components:
1081410836
budget_tokens:
1081510837
anyOf:
1081610838
- type: integer
10817-
minimum: 1.0
10839+
minimum: 1024.0
1081810840
- type: 'null'
1081910841
description: Maximum tokens for thinking.
1082010842
type: object

src/ogx/providers/inline/messages/impl.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
AnthropicImageBlock,
4747
AnthropicMessage,
4848
AnthropicMessageResponse,
49+
AnthropicRedactedThinkingBlock,
4950
AnthropicStreamEvent,
5051
AnthropicTextBlock,
5152
AnthropicThinkingBlock,
@@ -75,6 +76,7 @@
7576
_AnthropicErrorDetail,
7677
_InputJsonDelta,
7778
_MessageDelta,
79+
_SignatureDelta,
7880
_TextDelta,
7981
_ThinkingDelta,
8082
)
@@ -520,25 +522,31 @@ def _parse_sse_event(self, event_type: str, data: dict[str, Any]) -> AnthropicSt
520522
return MessageStartEvent(message=AnthropicMessageResponse(**data["message"]))
521523
if event_type == "content_block_start":
522524
block_data = data["content_block"]
523-
content_block: AnthropicTextBlock | AnthropicToolUseBlock | AnthropicThinkingBlock
525+
content_block: (
526+
AnthropicTextBlock | AnthropicToolUseBlock | AnthropicThinkingBlock | AnthropicRedactedThinkingBlock
527+
)
524528
block_type = block_data.get("type")
525529
if block_type == "tool_use":
526530
content_block = AnthropicToolUseBlock(**block_data)
527531
elif block_type == "thinking":
528532
content_block = AnthropicThinkingBlock(**block_data)
533+
elif block_type == "redacted_thinking":
534+
content_block = AnthropicRedactedThinkingBlock(**block_data)
529535
else:
530536
content_block = AnthropicTextBlock(**block_data)
531537
return ContentBlockStartEvent(index=data["index"], content_block=content_block)
532538
if event_type == "content_block_delta":
533539
delta_data = data["delta"]
534540
delta_type = delta_data.get("type")
535-
delta: _TextDelta | _InputJsonDelta | _ThinkingDelta
541+
delta: _TextDelta | _InputJsonDelta | _ThinkingDelta | _SignatureDelta
536542
if delta_type == "text_delta":
537543
delta = _TextDelta(text=delta_data["text"])
538544
elif delta_type == "input_json_delta":
539545
delta = _InputJsonDelta(partial_json=delta_data["partial_json"])
540546
elif delta_type == "thinking_delta":
541547
delta = _ThinkingDelta(thinking=delta_data["thinking"])
548+
elif delta_type == "signature_delta":
549+
delta = _SignatureDelta(signature=delta_data["signature"])
542550
else:
543551
return None
544552
return ContentBlockDeltaEvent(index=data["index"], delta=delta)
@@ -586,6 +594,12 @@ async def _passthrough_count_tokens(
586594
# -- Request translation --
587595

588596
def _anthropic_to_openai(self, request: AnthropicCreateMessageRequest) -> OpenAIChatCompletionRequestWithExtraBody:
597+
if request.thinking and request.thinking.type == "enabled":
598+
raise ValueError(
599+
"Failed to process thinking request: extended thinking requires a native "
600+
"Anthropic-compatible provider; translation mode does not support it"
601+
)
602+
589603
messages = self._convert_messages_to_openai(request.system, request.messages)
590604
tools = self._convert_tools_to_openai(request.tools) if request.tools else None
591605
# tool_choice without tools is an invalid combination for OpenAI-compatible backends.
@@ -597,8 +611,6 @@ def _anthropic_to_openai(self, request: AnthropicCreateMessageRequest) -> OpenAI
597611
extra_body: dict[str, Any] = {}
598612
if request.top_k is not None:
599613
extra_body["top_k"] = request.top_k
600-
# Note: Anthropic's "thinking" parameter has no equivalent in the OpenAI
601-
# chat completions API and is intentionally not forwarded.
602614

603615
params = OpenAIChatCompletionRequestWithExtraBody(
604616
model=request.model,

src/ogx_api/messages/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
AnthropicImageSource,
2828
AnthropicMessage,
2929
AnthropicMessageResponse,
30+
AnthropicRedactedThinkingBlock,
3031
AnthropicTextBlock,
3132
AnthropicTextEditorTool,
3233
AnthropicThinkingBlock,
@@ -72,6 +73,7 @@
7273
"AnthropicMessage",
7374
"AnthropicURLImageSource",
7475
"AnthropicMessageResponse",
76+
"AnthropicRedactedThinkingBlock",
7577
"AnthropicTextBlock",
7678
"AnthropicTextEditorTool",
7779
"AnthropicThinkingBlock",

src/ogx_api/messages/models.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,20 @@ class AnthropicThinkingBlock(BaseModel):
104104
cache_control: AnthropicCacheControl | None = None
105105

106106

107+
class AnthropicRedactedThinkingBlock(BaseModel):
108+
"""A redacted thinking content block. Must be echoed back as-is in multi-turn."""
109+
110+
type: Literal["redacted_thinking"] = "redacted_thinking"
111+
data: str = Field(..., description="Opaque redacted thinking data.")
112+
113+
107114
AnthropicContentBlock = Annotated[
108115
AnthropicTextBlock
109116
| AnthropicImageBlock
110117
| AnthropicToolUseBlock
111118
| AnthropicToolResultBlock
112-
| AnthropicThinkingBlock,
119+
| AnthropicThinkingBlock
120+
| AnthropicRedactedThinkingBlock,
113121
Field(discriminator="type"),
114122
]
115123

@@ -198,7 +206,7 @@ class AnthropicThinkingConfig(BaseModel):
198206
"""Configuration for extended thinking."""
199207

200208
type: Literal["enabled", "disabled", "adaptive"] = "enabled"
201-
budget_tokens: int | None = Field(default=None, ge=1, description="Maximum tokens for thinking.")
209+
budget_tokens: int | None = Field(default=None, ge=1024, description="Maximum tokens for thinking.")
202210

203211

204212
# -- Request models --
@@ -345,12 +353,17 @@ class _ThinkingDelta(BaseModel):
345353
thinking: str
346354

347355

356+
class _SignatureDelta(BaseModel):
357+
type: Literal["signature_delta"] = "signature_delta"
358+
signature: str
359+
360+
348361
class ContentBlockDeltaEvent(BaseModel):
349362
"""A delta within a content block."""
350363

351364
type: Literal["content_block_delta"] = "content_block_delta"
352365
index: int
353-
delta: _TextDelta | _InputJsonDelta | _ThinkingDelta
366+
delta: _TextDelta | _InputJsonDelta | _ThinkingDelta | _SignatureDelta
354367

355368

356369
class ContentBlockStopEvent(BaseModel):

0 commit comments

Comments
 (0)