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)
- Model ID with "abs-" prefix (Clacky AI proxy that speaks Bedrock Converse)

A bare “clacky-” key is NOT enough: that same workspace key is also used for dsk-*, or-*, and other OpenAI-compatible aliases served by the same Clacky proxy on a different endpoint. The *model prefix* is the source of truth for which upstream format the proxy expects:

abs-*  → Bedrock Converse  (POST /model/{id}/converse)
dsk-*  → OpenAI-compatible (POST /chat/completions)
or-*   → OpenAI-compatible (POST /chat/completions)
other  → depends on base_url + explicit anthropic_format flag

Historically this method also returned true for any “clacky-” key, which forced non-abs aliases into the Bedrock endpoint and produced ‘unknown model “…”` errors. Keep the explicit-prefix rule: if you add a new OpenAI-compatible alias family on the Clacky proxy, it will route correctly without touching this file.

Returns:

  • (Boolean)


39
40
41
42
# File 'lib/clacky/message_format/bedrock.rb', line 39

def self.bedrock_api_key?(api_key, model)
  return true if api_key.to_s.start_with?("ABSK")
  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



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/clacky/message_format/bedrock.rb', line 55

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)



144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/clacky/message_format/bedrock.rb', line 144

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: }



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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/clacky/message_format/bedrock.rb', line 94

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