#15006·kong

ai-proxy Bedrock: Claude Opus 5.5 chat responses lose `content` when reasoningContent is the first block

Author: kimnamuCreated Sep 23, 2026Updated Sep 23, 2026

Thanks for the Bedrock Converse driver (#13354) and for the multi-part Gemini handling in #14785. The same first-block pattern hits Bedrock, intermittently.

Claude Opus 5.5 (and Opus 5) on Converse often put a reasoningContent block before the text block without thinking being requested; how often depends on the prompt. from_bedrock_chat_openai reads only content[1].text, so those llm/v1/chat responses have no content. A quick Say hi comes back text-only and works.

Is there an existing issue for this?

  • I have searched the existing issues

Kong version ($ kong version)

master 3091de0 (3.10.0-dev); same line in 3.9.3. No Kong node: I ran kong/llm/drivers/bedrock.lua under LuaJIT 2.1 + lua-cjson with its Kong/nginx imports stubbed, on live Bedrock responses (us-east-1).

Current Behavior

First block is reasoningContent (live Converse, body from the driver's to_format):

prompt Opus 5.5 (us. + global.) Opus 5 (us.)
Say hi 0/6 0/3
What is 2+2? 1/6 2/3
Explain TCP slow start in two sentences. 5/6 3/3
Weather in Paris? brief 12/12 6/6
Converse response, us.anthropic.claude-opus-5-5 (signature and text trimmed):
{"output":{"message":{"content":[{"reasoningContent":{"reasoningText":{"signature":"CAQSvwMKEggS...","text":""}}},{"text":"I don't have live data, so I can't give you today's weather...."}],"role":"assistant"}},"stopReason":"end_turn"}

bedrock.from_format(<that response>, model, "llm/v1/chat") on master:
{"model":"us.anthropic.claude-opus-5-5","usage":{"completion_tokens":194,"prompt_tokens":18,"total_tokens":212},"choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":"stop"}],"object":"chat.completion"}

kong/llm/drivers/bedrock.lua L429:

lua
content = response.output.message.content[1].text,  -- may be nil

From reading kong/llm/init.lua L95 and L173-175 (not run), ai-request/response-transformer would then fail with no 'choices' in upstream AI service response.

Expected Behavior

content holds the text. Measured with the change below:

case (live Bedrock, us-east-1) Before (master) After
Opus 5.5 / Opus 5, reasoningContent first (29 of 45 plain runs) ❌ no content ✅ answer text
Opus 5.5 / Opus 5, text block only (16 of 45, e.g. Say hi) ✅ text ✅ unchanged
Sonnet 4.5 / Haiku 4.5 (text block only) ✅ text ✅ unchanged
Opus 5.5 / Opus 5 with one tool ✅ 1 tool_calls ✅ unchanged
Streaming Opus 5.5 / Opus 5 ✅ text deltas ✅ unchanged (not touched)
unit/real-responses/bedrock/llm-v1-chat.json ✅ ✅ unchanged

Steps To Reproduce

  1. Run bedrock.to_format on {"messages":[{"role":"user","content":"Weather in Paris? brief"}]}.
  2. POST it to /model/us.anthropic.claude-opus-5-5/converse.
  3. bedrock.from_format(<response>, ..., "llm/v1/chat"): choices[1].message has only role.

Anything else?

A possible change, following extract_text_from_content in kong/llm/drivers/anthropic.lua L319-331 (keeps every block with text, joins with "\n"):

lua
-- Claude Opus 5 / 5.5 can put a reasoningContent block before the text;
-- join the text blocks with "\n" like the anthropic driver does
local function join_text(content)
  local texts = {}
  for _, block in ipairs(content) do
    if block.text then
      table_insert(texts, block.text)
    end
  end

  return #texts > 0 and table.concat(texts, "\n") or nil
end

-- L429
content = join_text(response.output.message.content),  -- may be nil

I can open a PR with a fixture and changelog entry, or leave it for the next cumulative AI Gateway PR, whichever is easier for you.

RED / GREEN on the recorded live responses

On a node: ai-proxy, provider: bedrock, route_type: llm/v1/chat. luacheck (repo .luacheckrc) on the changed file: 0 warnings / 0 errors. A case passes when content equals the text blocks joined with "\n" and tool_calls matches the toolUse count. synthetic-two-text-blocks (reasoningContent, "a", "b", expects "a\nb") covers the join; no live reply had two text blocks.

### RED (master 3091de0), "Weather in Paris? brief" + tool + other models
FAIL global.anthropic.claude-opus-5-5-1                 reasoningContent,text  content=missing    tool_calls=0
FAIL global.anthropic.claude-opus-5-5-2                 reasoningContent,text  content=missing    tool_calls=0
FAIL global.anthropic.claude-opus-5-5-3                 reasoningContent,text  content=missing    tool_calls=0
ok   tools-us.anthropic.claude-opus-5-5                 toolUse                content=missing    tool_calls=1
ok   tools-us.anthropic.claude-opus-5                   text,toolUse           content=24 chars   tool_calls=1
ok   us.anthropic.claude-haiku-4-5-20251001-v1:0-1      text                   content=308 chars  tool_calls=0
FAIL us.anthropic.claude-opus-5-1                       reasoningContent,text  content=missing    tool_calls=0
FAIL us.anthropic.claude-opus-5-2                       reasoningContent,text  content=missing    tool_calls=0
FAIL us.anthropic.claude-opus-5-3                       reasoningContent,text  content=missing    tool_calls=0
FAIL us.anthropic.claude-opus-5-5-1                     reasoningContent,text  content=missing    tool_calls=0
FAIL us.anthropic.claude-opus-5-5-2                     reasoningContent,text  content=missing    tool_calls=0
FAIL us.anthropic.claude-opus-5-5-3                     reasoningContent,text  content=missing    tool_calls=0
ok   us.anthropic.claude-sonnet-4-5-20250929-v1:0-1     text                   content=166 chars  tool_calls=0
FAIL synthetic-two-text-blocks                          reasoningContent,text,text content=missing
4/14 passed

### GREEN (join_text), same set
ok   global.anthropic.claude-opus-5-5-1                 reasoningContent,text  content=378 chars  tool_calls=0
ok   global.anthropic.claude-opus-5-5-2                 reasoningContent,text  content=377 chars  tool_calls=0
ok   global.anthropic.claude-opus-5-5-3                 reasoningContent,text  content=409 chars  tool_calls=0
ok   tools-us.anthropic.claude-opus-5-5                 toolUse                content=missing    tool_calls=1
ok   tools-us.anthropic.claude-opus-5                   text,toolUse           content=24 chars   tool_calls=1
ok   us.anthropic.claude-haiku-4-5-20251001-v1:0-1      text                   content=308 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-1                       reasoningContent,text  content=324 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-2                       reasoningContent,text  content=290 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-3                       reasoningContent,text  content=301 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-5-1                     reasoningContent,text  content=406 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-5-2                     reasoningContent,text  content=450 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-5-3                     reasoningContent,text  content=425 chars  tool_calls=0
ok   us.anthropic.claude-sonnet-4-5-20250929-v1:0-1     text                   content=166 chars  tool_calls=0
ok   synthetic-two-text-blocks                          reasoningContent,text,text content="a\nb"
14/14 passed

Prompt set, 4 prompts x 3 runs x 3 models (p1 Say hi, p2 2+2, p3 TCP, p4 Paris weather). Master fails exactly the reasoningContent-first replies:

### RED (master 3091de0), excerpt of 37 lines
ok   global.anthropic.claude-opus-5-5-p1-1              text                   content=34 chars   tool_calls=0
ok   global.anthropic.claude-opus-5-5-p3-1              text                   content=463 chars  tool_calls=0
FAIL global.anthropic.claude-opus-5-5-p3-2              reasoningContent,text  content=missing    tool_calls=0
ok   us.anthropic.claude-opus-5-5-p1-1                  text                   content=34 chars   tool_calls=0
ok   us.anthropic.claude-opus-5-5-p2-2                  text                   content=13 chars   tool_calls=0
FAIL us.anthropic.claude-opus-5-5-p2-3                  reasoningContent,text  content=missing    tool_calls=0
FAIL us.anthropic.claude-opus-5-5-p3-1                  reasoningContent,text  content=missing    tool_calls=0
FAIL us.anthropic.claude-opus-5-p2-1                    reasoningContent,text  content=missing    tool_calls=0
ok   us.anthropic.claude-opus-5-p2-3                    text                   content=9 chars    tool_calls=0
16/37 passed

### GREEN (join_text), same excerpt
ok   global.anthropic.claude-opus-5-5-p1-1              text                   content=34 chars   tool_calls=0
ok   global.anthropic.claude-opus-5-5-p3-1              text                   content=463 chars  tool_calls=0
ok   global.anthropic.claude-opus-5-5-p3-2              reasoningContent,text  content=493 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-5-p1-1                  text                   content=34 chars   tool_calls=0
ok   us.anthropic.claude-opus-5-5-p2-2                  text                   content=13 chars   tool_calls=0
ok   us.anthropic.claude-opus-5-5-p2-3                  reasoningContent,text  content=13 chars   tool_calls=0
ok   us.anthropic.claude-opus-5-5-p3-1                  reasoningContent,text  content=492 chars  tool_calls=0
ok   us.anthropic.claude-opus-5-p2-1                    reasoningContent,text  content=9 chars    tool_calls=0
ok   us.anthropic.claude-opus-5-p2-3                    text                   content=9 chars    tool_calls=0
37/37 passed

### Streaming, ConverseStream events through handle_stream_event (same before and after)
us.anthropic.claude-opus-5-5: 31 content chunks, joined: I can't check live weather, so for today's forecast try weat
us.anthropic.claude-opus-5: 24 content chunks, joined: I can't check live weather — I don't have real-time data a

Stubs used to load the driver outside Kong:

lua
_G.ngx = { log = function() end, ERR = 4, WARN = 5, DEBUG = 8 }
package.loaded["kong.llm.drivers.shared"] = {
  merge_config_defaults = function(req) return req end,
  _CONST = { SSE_TERMINATOR = "[DONE]" },
}
package.loaded["socket.url"] = {}
package.loaded["resty.aws.request.sign"] = function() end
package.loaded["kong.llm.plugin.ctx"] = {
  get_global_accessors = function() return function() end, function() end end,
}
local bedrock = require("kong.llm.drivers.bedrock")

References: Claude Opus 5.5 announcement · migration guide · models overview