Module: Clacky::MessageFormat::Anthropic

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

Overview

Static helpers for Anthropic API message format.

Responsibilities:

- Identify Anthropic-style messages stored in @messages
- Convert internal @messages → Anthropic API request body
- Parse Anthropic API response → internal format
- Format tool results for the next turn

Internal @messages always use OpenAI-style canonical format:

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

This module converts that canonical format to Anthropic native on the way OUT, and converts Anthropic native back to canonical on the way IN.

Class Method Summary collapse

Class Method Details

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

Convert canonical @messages + tools into an Anthropic 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)

Returns:

  • (Hash)

    ready to serialize as JSON body



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/clacky/message_format/anthropic.rb', line 51

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

  system_text = system_messages.map { |m| extract_text(m[:content]) }.join("\n\n")

  api_messages = regular_messages.map { |msg| to_api_message(msg, caching_enabled) }
  api_tools    = tools&.map { |t| to_api_tool(t) }

  if caching_enabled && api_tools&.any?
    api_tools.last[:cache_control] = { type: "ephemeral" }
  end

  body = { model: model, max_tokens: max_tokens, messages: api_messages }
  body[:system] = system_text unless system_text.empty?
  body[:tools]  = api_tools   if api_tools&.any?
  body
end

.format_tool_results(response, tool_results) ⇒ Object

Format tool results into canonical messages to append to @messages. Input: response (canonical, has :tool_calls), tool_results array Output: canonical messages: [{ role: “tool”, tool_call_id:, content: }]



151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/clacky/message_format/anthropic.rb', line 151

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 Anthropic API response into canonical internal format.

Parameters:

  • data (Hash)

    parsed JSON response body

Returns:

  • (Hash)

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



75
76
77
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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/clacky/message_format/anthropic.rb', line 75

def parse_response(data)
  blocks  = data["content"] || []
  usage   = data["usage"]   || {}

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

  # tool_calls use canonical format (id, function: {name, arguments})
  tool_calls = blocks.select { |b| b["type"] == "tool_use" }.map do |tc|
    args = tc["input"].is_a?(String) ? tc["input"] : tc["input"].to_json
    { id: tc["id"], type: "function", name: tc["name"], arguments: args }
  end

  finish_reason = case data["stop_reason"]
                  when "end_turn"   then "stop"
                  when "tool_use"   then "tool_calls"
                  when "max_tokens" then "length"
                  else data["stop_reason"]
                  end

  # Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
  # input — cache_read_input_tokens and cache_creation_input_tokens are
  # reported separately and are disjoint from input_tokens.
  #
  # Normalise to the codebase's canonical shape (OpenAI-style) so downstream
  # (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
  # provider-agnostic:
  #
  #   prompt_tokens     = non_cached + cache_read     (OpenAI convention:
  #                                                    includes cache_read
  #                                                    but NOT cache_write;
  #                                                    ModelPricing does
  #                                                    `regular_input = prompt_tokens - cache_read`.)
  #   completion_tokens = output
  #   total_tokens      = THIS TURN'S new compute volume
  #                     = raw_input + cache_creation + output
  #                       (cache_read is excluded because hits are ~free /
  #                        already-paid-for; cache_creation IS new work this
  #                        turn even though it's billed at write_rate.)
  #   cache_read_input_tokens / cache_creation_input_tokens → independent fields
  #
  # total_tokens is purely presentational. CostTracker treats it as the
  # per-iteration delta directly (no subtraction of previous_total), which
  # is the correct reading when total_tokens already means "new work this
  # turn" rather than "cumulative".
  raw_input_tokens  = usage["input_tokens"].to_i
  cache_read        = usage["cache_read_input_tokens"].to_i
  cache_creation    = usage["cache_creation_input_tokens"].to_i
  output_tokens     = usage["output_tokens"].to_i

  prompt_tokens = raw_input_tokens + cache_read

  usage_data = {
    prompt_tokens:      prompt_tokens,
    completion_tokens:  output_tokens,
    # Per-turn new compute: what the server freshly processed this request.
    # Excludes cache_read (nearly free, already-paid-for).
    total_tokens:       raw_input_tokens + cache_creation + output_tokens,
    # Signal to CostTracker: total_tokens above is already the per-turn
    # delta (not a running cumulative like OpenAI's). CostTracker should
    # NOT subtract previous_total when this flag is truthy.
    # OpenAI parse leaves this field unset; Bedrock may adopt the same
    # convention in future if we normalise it there too.
    total_is_per_turn: true
  }
  usage_data[:cache_read_input_tokens]     = cache_read     if cache_read     > 0
  usage_data[:cache_creation_input_tokens] = cache_creation if cache_creation > 0

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

.tool_result_message?(msg) ⇒ Boolean

Returns true if the message is an Anthropic-native tool result stored in NOTE: After the refactor, new tool results are stored in canonical format (role: “tool”). This helper handles legacy messages that might exist in older sessions.

Returns:

  • (Boolean)


29
30
31
32
33
# File 'lib/clacky/message_format/anthropic.rb', line 29

def tool_result_message?(msg)
  msg[:role] == "user" &&
    msg[:content].is_a?(Array) &&
    msg[:content].any? { |b| b.is_a?(Hash) && b[:type] == "tool_result" }
end

.tool_use_ids(msg) ⇒ Object

Returns the tool_use_ids referenced in an Anthropic-native tool result message.



36
37
38
39
40
# File 'lib/clacky/message_format/anthropic.rb', line 36

def tool_use_ids(msg)
  return [] unless tool_result_message?(msg)

  msg[:content].select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] }
end