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



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

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



242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/clacky/message_format/anthropic.rb', line 242

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

.normalise_prompt_tokens(input_tokens, cache_read, cache_creation) ⇒ Object

Normalise a /v1/messages usage block to the codebase's canonical (OpenAI-style) prompt_tokens: total input including cache_read but excluding cache_creation.

Anthropic reports input_tokens as the post-breakpoint tail only, with the cache buckets disjoint from it. Some /v1/messages-compatible gateways instead report input_tokens as the FULL input with both cache buckets already folded in; adding cache_read there would bill the whole cached prefix twice. Detect that by whether the cache buckets already fit inside input_tokens.



32
33
34
35
36
37
38
39
# File 'lib/clacky/message_format/anthropic.rb', line 32

def normalise_prompt_tokens(input_tokens, cache_read, cache_creation)
  cached_input = cache_read + cache_creation
  if cached_input.positive? && input_tokens >= cached_input
    input_tokens - cache_creation
  else
    input_tokens + cache_read
  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: }



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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/clacky/message_format/anthropic.rb', line 172

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

  # Normalise to the codebase's canonical shape (OpenAI-style) so downstream
  # (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
  # provider-agnostic:
  #
  #   prompt_tokens     = total input incl. cache_read, excl. cache_write
  #                       (ModelPricing does
  #                        `regular_input = prompt_tokens - cache_read`.)
  #   completion_tokens = output
  #   total_tokens      = THIS TURN'S new compute volume
  #                     = non_cached + 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 = normalise_prompt_tokens(raw_input_tokens, cache_read, cache_creation)

  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). Derived from the
    # normalised prompt_tokens so it stays correct under both conventions.
    total_tokens:       (prompt_tokens - cache_read) + 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).



66
67
68
69
70
# File 'lib/clacky/message_format/anthropic.rb', line 66

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)


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

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.



55
56
57
58
59
# File 'lib/clacky/message_format/anthropic.rb', line 55

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