summaryrefslogtreecommitdiffhomepage
path: root/lib/dispatch/adapter/claude/response_builder.rb
blob: 1672b9e021705a15aa95376f056f40c7235eebf1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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
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
# frozen_string_literal: true

module Dispatch
  module Adapter
    class Claude < Base
      # Converts an Anthropic JSON response Hash (already parsed) into a
      # Dispatch::Adapter::Response together with its Usage / UsageCost.
      #
      # Entry point:
      #   ResponseBuilder.build(json, model_info:, is_oauth:) #=> Response
      module ResponseBuilder
        # Stop-reason mapping from Anthropic strings to interface symbols.
        STOP_REASON_MAP = {
          "end_turn" => :end_turn,
          "max_tokens" => :max_tokens,
          "tool_use" => :tool_use,
          "pause_turn" => :pause_turn,
          "refusal" => :refusal,
          "sensitive" => :sensitive,
          "stop_sequence" => :end_turn # we never send stop sequences
        }.freeze

        module_function

        # Build a Response from a parsed Anthropic JSON body.
        #
        # @param json       [Hash]      the parsed response body
        # @param model_info [ModelInfo] used for pricing
        # @param is_oauth   [Boolean]   strip proxy_ prefix from tool_use names
        # @return [Dispatch::Adapter::Response]
        def build(json, model_info:, is_oauth:)
          content_blocks, tool_calls = parse_content(json["content"] || [], is_oauth: is_oauth)
          stop_reason = map_stop_reason(json["stop_reason"])
          usage       = build_usage(json["usage"] || {}, model_info: model_info)
          model_id    = json["model"] || model_info&.id

          Response.new(
            content: content_blocks,
            tool_calls: tool_calls,
            model: model_id,
            stop_reason: stop_reason,
            usage: usage
          )
        end

        # Parse the content array from the Anthropic response.
        # Returns [content_blocks, tool_calls] where:
        #   content_blocks = [TextBlock, ThinkingBlock, RedactedThinkingBlock, …]
        #   tool_calls     = [ToolUseBlock, …]
        def parse_content(content_array, is_oauth:)
          blocks     = []
          tool_calls = []

          Array(content_array).each do |item|
            next unless item.is_a?(Hash)

            type = item["type"].to_s
            case type
            when "text"
              text = item["text"].to_s
              blocks << TextBlock.new(text: text) unless text.empty?

            when "thinking"
              thinking  = item["thinking"].to_s
              signature = item["signature"].to_s
              blocks << ThinkingBlock.new(thinking: thinking, signature: signature.empty? ? nil : signature)

            when "redacted_thinking"
              data = item["data"].to_s
              blocks << RedactedThinkingBlock.new(data: data) unless data.empty?

            when "tool_use"
              name = item["name"].to_s
              name = Cloaking.strip_prefix(name) if is_oauth
              tool_calls << ToolUseBlock.new(
                id: item["id"].to_s,
                name: name,
                arguments: item["input"] || {}
              )
            end
          end

          [blocks, tool_calls]
        end

        # ── Stop-reason mapping ──────────────────────────────────────────────

        def map_stop_reason(raw)
          STOP_REASON_MAP.fetch(raw.to_s, :end_turn)
        end

        # ── Usage / cost building ────────────────────────────────────────────

        def build_usage(usage_hash, model_info:)
          input_tokens      = usage_hash["input_tokens"].to_i
          output_tokens     = usage_hash["output_tokens"].to_i
          cache_read_tokens = usage_hash["cache_read_input_tokens"].to_i
          cache_creation_tokens = usage_hash["cache_creation_input_tokens"].to_i

          usage = Usage.new(
            input_tokens: input_tokens,
            output_tokens: output_tokens,
            cache_read_tokens: cache_read_tokens,
            cache_creation_tokens: cache_creation_tokens
          )

          usage.cost = Pricing.calculate(usage, model_info)
          usage
        end
      end
    end
  end
end