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, reasoning_effort: nil) ⇒ 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



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
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
# File 'lib/clacky/message_format/anthropic.rb', line 62

def build_request_body(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil)
  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_messages = merge_consecutive_tool_results(api_messages)
  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?

  # Kimi (Moonshot Coding Plan) models routed through the Anthropic
  # /v1/messages format expect thinking:{type:"enabled"} — the native
  # Anthropic "adaptive" type is Claude-specific and is silently ignored
  # by the Kimi backend, so thinking never actually activates on K3.
  #
  # K3 additionally supports a "max" effort level (strongest reasoning)
  # beyond the standard low/medium/high triple. The generic
  # normalized_effort() helper drops "max" because it only whitelists
  # the standard three levels, making K3's signature strongest-reasoning
  # mode unreachable through this format adapter.
  #
  # This branch only changes K3's wire format; every other model keeps
  # using the adaptive path below unchanged. When reasoning_effort is
  # nil/empty we leave the body untouched (provider default), and we
  # never emit output_config for "on" so the backend can pick its own
  # default — both behaviours match the official kimi CLI.
  if model.to_s.match?(/\A(kimi-)?k3/i)
    effort_str = reasoning_effort.to_s
    k3_effort =
      case effort_str
      when "max", "xhigh"              then "max"
      when "high", "medium", "low"     then effort_str
      else                                  nil
      end
    body[:thinking] = { type: "enabled" }
    body[:output_config] = { effort: k3_effort } if k3_effort
  elsif (effort = normalized_effort(reasoning_effort))
    body[:thinking] = { type: "adaptive" }
    body[:output_config] = { effort: effort }
  end

  body
end

.format_tool_results(response, tool_results) ⇒ Object

── Tool result formatting ──────────────────────────────────────────────── 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: }]



228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/clacky/message_format/anthropic.rb', line 228

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



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/clacky/message_format/anthropic.rb', line 153

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

.sanitize_tool_use_id(id) ⇒ Object

Anthropic requires tool_use.id to match ^[a-zA-Z0-9_-]+$ (max 128 chars). Some OpenAI-compatible upstreams (e.g. kimi-k2.6) return ids like "tool_name:0" — fine for OpenAI, rejected by Anthropic. We replace illegal chars with "_" at the format boundary so ids stay self-consistent across use/result pairs (pure function → same input maps to same output in both directions).



47
48
49
50
51
# File 'lib/clacky/message_format/anthropic.rb', line 47

def sanitize_tool_use_id(id)
  s = id.to_s
  s = s.gsub(/[^a-zA-Z0-9_-]/, "_")
  s.length > 128 ? s[0, 128] : s
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