Module: Clacky::MessageFormat::Bedrock

Defined in:
lib/clacky/message_format/bedrock.rb

Overview

Static helpers for AWS Bedrock Converse API message format.

The Bedrock Converse API has a completely different format from Anthropic’s Messages API:

- Authentication: Authorization: Bearer <ABSK...key>
- Endpoint: POST /model/{modelId}/converse
- Request:  { messages: [{role:, content: [{text:}]}], toolConfig: {tools: [{toolSpec:...}]}, system: [{text:}] }
- Response: { output: { message: { role:, content: [{text:} or {toolUse:}] } }, stopReason:, usage: }

Internal canonical format (same as OpenAI-style):

assistant tool_calls: { role: "assistant", tool_calls: [{id:, name:, arguments:}] }
tool result:          { role: "tool", tool_call_id:, content: }

This module converts canonical format ↔ Bedrock Converse API format.

Class Method Summary collapse

Class Method Details

.bedrock_api_key?(api_key, model) ⇒ Boolean

Detect if the request should use the Bedrock Converse API. Matches any of:

- API key with "ABSK" prefix (native AWS Bedrock)
- API key with "clacky-" prefix (Clacky workspace key, proxied via Bedrock Converse)
- Model ID with "abs-" prefix (Clacky AI proxy that speaks Bedrock Converse)

Returns:

  • (Boolean)


24
25
26
# File 'lib/clacky/message_format/bedrock.rb', line 24

def self.bedrock_api_key?(api_key, model)
  api_key.to_s.start_with?("ABSK", "clacky-") || model.to_s.start_with?("abs-")
end

.build_request_body(messages, model, tools, max_tokens, caching_enabled = false) ⇒ Hash

Convert canonical @messages + tools into a Bedrock Converse API request body.

Parameters:

  • messages (Array<Hash>)

    canonical messages (may include system)

  • model (String)
  • tools (Array<Hash>)

    OpenAI-style tool definitions

  • max_tokens (Integer)
  • caching_enabled (Boolean) (defaults to: false)

    (currently unused for Bedrock)

Returns:

  • (Hash)

    ready to serialize as JSON body



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/clacky/message_format/bedrock.rb', line 39

def build_request_body(messages, model, tools, max_tokens, caching_enabled = false)
  system_messages = messages.select { |m| m[:role] == "system" }
  regular_messages = messages.reject { |m| m[:role] == "system" }

  # Merge consecutive same-role messages (Bedrock requires alternating roles)
  api_messages = merge_consecutive_tool_results(regular_messages.map { |msg| to_api_message(msg) })

  # Inject cachePoint blocks AFTER conversion to Bedrock API format.
  # Doing this on canonical messages (before to_api_message) is incorrect because
  # tool-result messages (role: "tool") are converted to toolResult blocks, and
  # Bedrock does not support cachePoint inside toolResult.content.
  # Operating on the final Bedrock format ensures cachePoint is always a top-level
  # sibling block in the message's content array, which is what Bedrock expects.
  api_messages = apply_api_caching(api_messages) if caching_enabled

  body = { messages: api_messages }

  # Add system prompt if present
  unless system_messages.empty?
    system_text = system_messages.map { |m| extract_text(m[:content]) }.join("\n\n")
    body[:system] = [{ text: system_text }] unless system_text.empty?
  end

  # Add inference config for max_tokens
  body[:inferenceConfig] = { maxTokens: max_tokens }

  # Add tool config if tools are provided
  if tools&.any?
    body[:toolConfig] = { tools: tools.map { |t| to_api_tool(t) } }
  end

  body
end

.format_tool_results(response, tool_results) ⇒ Object

Format tool results into canonical messages to append to @messages. (Same as Anthropic format — canonical tool messages)



128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/clacky/message_format/bedrock.rb', line 128

def format_tool_results(response, tool_results)
  results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }

  response[:tool_calls].map do |tc|
    result = results_map[tc[:id]]
    {
      role: "tool",
      tool_call_id: tc[:id],
      content: result ? result[:content] : { error: "Tool result missing" }.to_json
    }
  end
end

.parse_response(data) ⇒ Hash

Parse Bedrock Converse API response into canonical internal format.

Parameters:

  • data (Hash)

    parsed JSON response body

Returns:

  • (Hash)

    canonical response: { content:, tool_calls:, finish_reason:, usage: }



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/clacky/message_format/bedrock.rb', line 78

def parse_response(data)
  message = data.dig("output", "message") || {}
  blocks  = message["content"] || []
  usage   = data["usage"] || {}

  # Extract text content
  content = blocks.select { |b| b["text"] }.map { |b| b["text"] }.join("")

  # Extract tool calls from toolUse blocks
  tool_calls = blocks.select { |b| b["toolUse"] }.map do |b|
    tc = b["toolUse"]
    args = tc["input"].is_a?(String) ? tc["input"] : tc["input"].to_json
    { id: tc["toolUseId"], type: "function", name: tc["name"], arguments: args }
  end

  # Map Bedrock stopReason → canonical finish_reason
  finish_reason = case data["stopReason"]
                  when "end_turn"   then "stop"
                  when "tool_use"   then "tool_calls"
                  when "max_tokens" then "length"
                  else data["stopReason"]
                  end

  cache_read  = usage["cacheReadInputTokens"].to_i
  cache_write = usage["cacheWriteInputTokens"].to_i

  # Bedrock `inputTokens` = non-cached input only.
  # Anthropic direct `input_tokens` = all input including cache_read.
  # Normalise to Anthropic semantics so ModelPricing.calculate_cost works correctly:
  #   prompt_tokens = inputTokens + cacheReadInputTokens
  # (calculate_cost subtracts cache_read_tokens from prompt_tokens to get
  #  the billable non-cached portion; that arithmetic requires the Anthropic convention.)
  prompt_tokens = usage["inputTokens"].to_i + cache_read

  usage_data = {
    prompt_tokens:     prompt_tokens,
    completion_tokens: usage["outputTokens"].to_i,
    total_tokens:      usage["totalTokens"].to_i
  }
  usage_data[:cache_read_input_tokens]     = cache_read  if cache_read  > 0
  usage_data[:cache_creation_input_tokens] = cache_write if cache_write > 0

  { content: content, tool_calls: tool_calls, finish_reason: finish_reason,
    usage: usage_data, raw_api_usage: usage }
end