Module: Octo::MessageFormat::Anthropic

Defined in:
lib/octo/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, base_url: 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)
  • reasoning_effort (String, nil) (defaults to: nil)
  • base_url (String, nil) (defaults to: nil)

    used to detect third-party Anthropic-compatible endpoints (e.g. Kimi /coding, DeepSeek /anthropic) so we can strip thinking-block signatures they cannot re-validate.

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

def build_request_body(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil, base_url: 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")

  # Detect non-native Anthropic endpoints that speak the Anthropic protocol
  # but cannot validate Anthropic-proprietary thinking signatures.
  strip_thinking_signatures = base_url && !native_anthropic_endpoint?(base_url)

  api_messages = regular_messages.map { |msg| to_api_message(msg, caching_enabled, strip_thinking_signatures) }
  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?

  if (effort = normalized_effort(reasoning_effort))
    body[:thinking] = { type: "adaptive" }
    body[:output_config] = { effort: effort }
  end

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



184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/octo/message_format/anthropic.rb', line 184

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



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
145
146
147
148
149
150
151
152
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
# File 'lib/octo/message_format/anthropic.rb', line 102

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

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

  reasoning_content = blocks.select { |b| b["type"] == "thinking" }.map { |b| b["thinking"] }.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

  result = {
    content: content, tool_calls: tool_calls, finish_reason: finish_reason,
    usage: usage_data, raw_api_usage: usage
  }
  result[:reasoning_content] = reasoning_content unless reasoning_content.empty?
  result
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/octo/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/octo/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