llm_gateway 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +565 -129
  4. data/Rakefile +8 -3
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +97 -1
  41. data/lib/llm_gateway/client.rb +66 -54
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +23 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +169 -10
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -21
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -56
  60. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
  65. data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
data/Rakefile CHANGED
@@ -10,10 +10,9 @@ require "rubocop/rake_task"
10
10
  RuboCop::RakeTask.new
11
11
 
12
12
  begin
13
- require "gem/release"
14
-
15
13
  desc "Release with changelog"
16
14
  task :gem_release do
15
+ require "gem/release"
17
16
  # Safety checks: ensure we're on main and up-to-date
18
17
  current_branch = `git branch --show-current`.strip
19
18
  unless current_branch == "main"
@@ -62,12 +61,18 @@ begin
62
61
  sh "git add ."
63
62
  sh "git commit -m \"Bump llm_gateway to #{new_version}\""
64
63
 
64
+ # Build the gem first
65
+ gem_file = `gem build llm_gateway.gemspec | grep 'File:' | awk '{print $2}'`.strip
66
+
65
67
  # Tag and push
66
68
  sh "git tag v#{new_version}"
67
69
  sh "git push origin main --tags"
68
70
 
71
+ # Create GitHub release with gem file
72
+ sh "gh release create v#{new_version} #{gem_file} --title \"Release v#{new_version}\" --generate-notes"
73
+
69
74
  # Release the gem
70
- sh "gem push $(gem build llm_gateway.gemspec | grep 'File:' | awk '{print $2}')"
75
+ sh "gem push #{gem_file}"
71
76
  end
72
77
  rescue LoadError
73
78
  # gem-release not available in this environment
@@ -0,0 +1,135 @@
1
+ # Dont use LlmGateway::Client
2
+
3
+ use the provider pattern instead
4
+ # Migrating from `chat` to `stream`
5
+
6
+ The `chat` method will be deprecated. New code should use `stream`.
7
+
8
+ If your application only needs the final assistant response, call `stream` without a block. You do not need to handle streaming events.
9
+
10
+ ## Basic migration
11
+
12
+ ### Before
13
+
14
+ ```ruby
15
+ result = adapter.chat("Write one short sentence about Ruby.")
16
+ ```
17
+
18
+ ### After
19
+
20
+ ```ruby
21
+ result = adapter.stream("Write one short sentence about Ruby.")
22
+ ```
23
+
24
+ `stream` returns the final assembled `AssistantMessage`, so most response-handling code can stay the same.
25
+
26
+ ## Migrating calls with options
27
+
28
+ Pass the same options to `stream` that you passed to `chat`.
29
+
30
+ ### Before
31
+
32
+ ```ruby
33
+ result = adapter.chat(
34
+ transcript,
35
+ tools: tools,
36
+ system: "You are a helpful assistant.",
37
+ reasoning: "high"
38
+ )
39
+ ```
40
+
41
+ ### After
42
+
43
+ ```ruby
44
+ result = adapter.stream(
45
+ transcript,
46
+ tools: tools,
47
+ system: "You are a helpful assistant.",
48
+ reasoning: "high"
49
+ )
50
+ ```
51
+
52
+ ## Reading the final response
53
+
54
+ The returned object has the same final assistant message shape your existing `chat` code expects:
55
+
56
+ ```ruby
57
+ result = adapter.stream(transcript)
58
+
59
+ puts result.role
60
+ puts result.stop_reason
61
+ puts result.usage.inspect
62
+
63
+ text = result.content
64
+ .select { |block| block.type == "text" }
65
+ .map(&:text)
66
+ .join
67
+
68
+ puts text
69
+ ```
70
+
71
+ ## Tool-call flows
72
+
73
+ If your existing `chat` flow inspected the final response for tool calls, keep the same pattern after switching to `stream`:
74
+
75
+ ```ruby
76
+ response = adapter.stream(transcript, tools: [weather_tool])
77
+
78
+ tool_uses = response.content.select { |block| block.type == "tool_use" }
79
+
80
+ # Execute tools, append tool_result messages to the transcript,
81
+ # then call stream again for the next assistant response.
82
+ ```
83
+
84
+ ## Recommended migration steps
85
+
86
+ 1. Replace `adapter.chat(...)` with `adapter.stream(...)`.
87
+ 2. Do not pass a block if you only need the final response.
88
+ 3. Run tests that verify text extraction, tool-call detection, stop reasons, and usage accounting.
89
+ 4. Remove new uses of `chat` from application code before deprecation.
90
+
91
+ # Update ClassNames
92
+
93
+ If you are using any of these classes you should use the new names
94
+
95
+ ## Clients
96
+
97
+ - LlmGateway::Clients::Claude → LlmGateway::Clients::Anthropic
98
+ - LlmGateway::Clients::OpenAi → LlmGateway::Clients::OpenAI
99
+
100
+ ## Adapters: Anthropic side
101
+
102
+ - LlmGateway::Adapters::Claude::* → LlmGateway::Adapters::Anthropic::*
103
+ - Client
104
+ - MessagesAdapter
105
+ - InputMapper
106
+ - OutputMapper
107
+ - StreamMapper
108
+ - BidirectionalMessageMapper
109
+ - FileOutputMapper
110
+
111
+ ## Adapters: OpenAI side
112
+
113
+ - LlmGateway::Adapters::OpenAi::* → LlmGateway::Adapters::OpenAI::*
114
+ - Client
115
+ - ChatCompletionsAdapter
116
+ - ResponsesAdapter
117
+ - PromptCacheOptionMapper
118
+ - FileOutputMapper
119
+ - ChatCompletions
120
+ - Responses
121
+
122
+ ## Adapters: Groq side
123
+
124
+ Groq now reuses the OpenAI Chat Completions mapper stack via `ActsLikeOpenAIChatCompletions`.
125
+ The dedicated Groq mapper classes were removed rather than aliased:
126
+
127
+ - LlmGateway::Adapters::Groq::InputMapper → LlmGateway::Adapters::OpenAI::ChatCompletions::InputMapper
128
+ - LlmGateway::Adapters::Groq::OutputMapper → LlmGateway::Adapters::OpenAI::ChatCompletions::OutputMapper
129
+ - LlmGateway::Adapters::Groq::BidirectionalMessageMapper → LlmGateway::Adapters::OpenAI::ChatCompletions::BidirectionalMessageMapper
130
+
131
+ Still provider-specific:
132
+
133
+ - LlmGateway::Clients::Groq
134
+ - LlmGateway::Adapters::Groq::ChatCompletionsAdapter
135
+ - LlmGateway::Adapters::Groq::OptionMapper
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stream_accumulator"
4
+ require_relative "structs"
5
+
6
+ module LlmGateway
7
+ module Adapters
8
+ class Adapter
9
+ attr_reader :client
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ def chat(message, tools: nil, system: nil, **options)
16
+ normalized_input = map_input({
17
+ messages: sanitize_messages(normalize_messages(message)),
18
+ tools: tools,
19
+ system: normalize_system(system)
20
+ })
21
+
22
+ result = perform_chat(
23
+ normalized_input[:messages],
24
+ tools: normalized_input[:tools],
25
+ system: normalized_input[:system],
26
+ **map_options(options)
27
+ )
28
+
29
+ map_output(result)
30
+ end
31
+
32
+ def stream(message, tools: nil, system: nil, **options, &block)
33
+ raise LlmGateway::Errors::MissingMapperForProvider, "No stream_mapper configured" unless stream_mapper
34
+
35
+ normalized_input = map_input({
36
+ messages: sanitize_messages(normalize_messages(message)),
37
+ tools: tools,
38
+ system: normalize_system(system)
39
+ })
40
+
41
+ accumulator = ::StreamAccumulator.new
42
+ mapper = stream_mapper.new
43
+
44
+ perform_stream(
45
+ normalized_input[:messages],
46
+ tools: normalized_input[:tools],
47
+ system: normalized_input[:system],
48
+ **map_options(options)
49
+ ) do |chunk|
50
+ event = mapper.map(chunk)
51
+ accumulator.push(event)
52
+ block.call(event) if block && event
53
+ end
54
+
55
+ AssistantMessage.new(
56
+ accumulator.result.merge(
57
+ provider: LlmGateway::Client.provider_id_from_client(client),
58
+ api: api_name
59
+ )
60
+ )
61
+ end
62
+
63
+ def upload_file(filename:, content:, mime_type: "application/octet-stream", purpose: "assistants")
64
+ raise LlmGateway::Errors::MissingMapperForProvider, "No file_output_mapper configured" unless file_output_mapper
65
+
66
+ upload_params = client.method(:upload_file).parameters
67
+ supports_purpose = upload_params.any? { |type, name| [ :key, :keyreq ].include?(type) && name == :purpose }
68
+
69
+ result = if supports_purpose
70
+ client.upload_file(filename, content, mime_type, purpose: purpose)
71
+ else
72
+ client.upload_file(filename, content, mime_type)
73
+ end
74
+
75
+ file_output_mapper.map(result)
76
+ end
77
+
78
+ def download_file(file_id:)
79
+ raise LlmGateway::Errors::MissingMapperForProvider, "No file_output_mapper configured" unless file_output_mapper
80
+
81
+ result = client.download_file(file_id)
82
+ file_output_mapper.map(result)
83
+ end
84
+
85
+ private
86
+
87
+ def input_mapper
88
+ raise NotImplementedError, "#{self.class} must implement #input_mapper"
89
+ end
90
+
91
+ def input_sanitizer
92
+ nil
93
+ end
94
+
95
+ def output_mapper
96
+ raise NotImplementedError, "#{self.class} must implement #output_mapper"
97
+ end
98
+
99
+ def file_output_mapper
100
+ nil
101
+ end
102
+
103
+ def option_mapper
104
+ OptionMapper
105
+ end
106
+
107
+ def map_input(input)
108
+ input_mapper.map(input)
109
+ end
110
+
111
+ def map_output(output)
112
+ output_mapper.map(output)
113
+ end
114
+
115
+ def map_options(options)
116
+ option_mapper.map(options)
117
+ end
118
+
119
+ def perform_chat(messages, tools:, system:, **options)
120
+ client.chat(messages, tools: tools, system: system, **options)
121
+ end
122
+
123
+ def perform_stream(messages, tools:, system:, **options, &block)
124
+ client.stream(messages, tools: tools, system: system, **options, &block)
125
+ end
126
+
127
+ def api_name
128
+ self.class.name.split("::").last.gsub(/Adapter$/, "").downcase
129
+ end
130
+
131
+ def stream_mapper
132
+ nil
133
+ end
134
+
135
+ def sanitize_messages(messages)
136
+ return messages unless input_sanitizer
137
+
138
+ target_provider = LlmGateway::Client.provider_id_from_client(client)
139
+ target_api = api_name
140
+ target_model = client.model_key
141
+
142
+ return messages if target_provider.nil? || target_api.nil? || target_model.nil?
143
+
144
+ input_sanitizer.sanitize(
145
+ messages,
146
+ target_provider: target_provider,
147
+ target_api: target_api,
148
+ target_model: target_model
149
+ )
150
+ end
151
+
152
+ def normalize_system(system)
153
+ if system.nil?
154
+ []
155
+ elsif system.is_a?(String)
156
+ [ { role: "system", content: system } ]
157
+ elsif system.is_a?(Array)
158
+ system
159
+ else
160
+ raise ArgumentError, "System parameter must be a string or array, got #{system.class}"
161
+ end
162
+ end
163
+
164
+ def normalize_messages(message)
165
+ if message.is_a?(String)
166
+ [ { role: "user", content: message } ]
167
+ else
168
+ message
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module ActsLikeAnthropicMessages
6
+ private
7
+
8
+ def api_name = "messages"
9
+
10
+ def input_mapper = Anthropic::InputMapper
11
+
12
+ def input_sanitizer = InputMessageSanitizer
13
+
14
+ def output_mapper = Anthropic::OutputMapper
15
+
16
+ def file_output_mapper = Anthropic::FileOutputMapper
17
+
18
+ def option_mapper = AnthropicOptionMapper
19
+
20
+ def stream_mapper = Anthropic::StreamMapper
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module Anthropic
6
+ class BidirectionalMessageMapper
7
+ attr_reader :direction
8
+
9
+ def initialize(direction)
10
+ @direction = direction
11
+ end
12
+
13
+ def map_content(content)
14
+ # Convert string content to text format
15
+ content = { type: "text", text: content } unless content.is_a?(Hash)
16
+
17
+ case content[:type]
18
+ when "text"
19
+ map_text_content(content)
20
+ when "file"
21
+ map_file_content(content)
22
+ when "image"
23
+ map_image_content(content)
24
+ when "tool_use"
25
+ map_tool_use_content(content)
26
+ when "tool_result"
27
+ map_tool_result_content(content)
28
+ when "thinking", "reasoning"
29
+ map_reasoning_content(content)
30
+ else
31
+ content
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def map_text_content(content)
38
+ result = {
39
+ type: "text",
40
+ text: content[:text]
41
+ }
42
+ result[:cache_control] = content[:cache_control] if content[:cache_control]
43
+ result
44
+ end
45
+
46
+ def map_file_content(content)
47
+ {
48
+ type: "document",
49
+ source: {
50
+ data: content[:data],
51
+ type: "text",
52
+ media_type: content[:media_type]
53
+ }
54
+ }
55
+ end
56
+
57
+ def map_image_content(content)
58
+ {
59
+ type: "image",
60
+ source: {
61
+ data: content[:data],
62
+ type: "base64",
63
+ media_type: content[:media_type]
64
+ }
65
+ }
66
+ end
67
+
68
+ def map_tool_use_content(content)
69
+ {
70
+ type: "tool_use",
71
+ id: content[:id],
72
+ name: content[:name],
73
+ input: content[:input]
74
+ }
75
+ end
76
+
77
+ def map_tool_result_content(content)
78
+ mapped_content = content[:content]
79
+ if mapped_content.is_a?(Array)
80
+ mapped_content = mapped_content.map do |item|
81
+ item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
82
+ end
83
+ end
84
+
85
+ {
86
+ type: "tool_result",
87
+ tool_use_id: content[:tool_use_id],
88
+ content: mapped_content
89
+ }
90
+ end
91
+
92
+ def map_reasoning_content(content)
93
+ if direction == LlmGateway::DIRECTION_IN
94
+ result = {
95
+ type: "thinking",
96
+ thinking: content[:reasoning]
97
+ }
98
+ result[:signature] = content[:signature] unless content[:signature].nil?
99
+ result
100
+ else
101
+ {
102
+ type: "reasoning",
103
+ reasoning: content[:thinking] || content[:reasoning],
104
+ signature: content[:signature]
105
+ }
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "bidirectional_message_mapper"
4
+
3
5
  module LlmGateway
4
6
  module Adapters
5
- module Claude
7
+ module Anthropic
6
8
  class InputMapper
7
9
  def self.map(data)
8
10
  {
9
11
  messages: map_messages(data[:messages]),
10
- response_format: data[:response_format],
11
12
  tools: data[:tools],
12
13
  system: map_system(data[:system])
13
14
  }
@@ -18,20 +19,19 @@ module LlmGateway
18
19
  def self.map_messages(messages)
19
20
  return messages unless messages
20
21
 
22
+ message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_IN)
23
+
21
24
  messages.map do |msg|
22
25
  msg = msg.merge(role: "user") if msg[:role] == "developer"
23
- msg.slice(:role, :content)
26
+
24
27
  content = if msg[:content].is_a?(Array)
25
28
  msg[:content].map do |content|
26
- if content[:type] == "file"
27
- { type: "document", source: { data: content[:data], type: "text", media_type: content[:media_type] } }
28
- else
29
- content
30
- end
29
+ message_mapper.map_content(content)
31
30
  end
32
31
  else
33
- msg[:content]
32
+ [ message_mapper.map_content(msg[:content]) ]
34
33
  end
34
+
35
35
  {
36
36
  role: msg[:role],
37
37
  content: content
@@ -44,7 +44,9 @@ module LlmGateway
44
44
  nil
45
45
  elsif system.length == 1 && system.first[:role] == "system"
46
46
  # If we have a single system message, convert to Claude format
47
- [ { type: "text", text: system.first[:content] } ]
47
+ mapped = { type: "text", text: system.first[:content] }
48
+ mapped[:cache_control] = system.first[:cache_control] if system.first[:cache_control]
49
+ [ mapped ]
48
50
  else
49
51
  # For multiple messages or non-standard format, pass through
50
52
  system
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../adapter"
4
+ require_relative "acts_like_messages"
5
+ require_relative "../anthropic_option_mapper"
6
+ require_relative "../input_message_sanitizer"
7
+ require_relative "input_mapper"
8
+ require_relative "output_mapper"
9
+ require_relative "stream_mapper"
10
+
11
+ module LlmGateway
12
+ module Adapters
13
+ module Anthropic
14
+ class MessagesAdapter < Adapter
15
+ include ActsLikeAnthropicMessages
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module Anthropic
6
+ class FileOutputMapper
7
+ def self.map(data)
8
+ data.delete(:type) # Didnt see much value in this only option is "file"
9
+ data.merge(
10
+ expires_at: nil, # came from open ai api
11
+ purpose: "user_data", # came from open ai api
12
+ )
13
+ end
14
+ end
15
+
16
+ class OutputMapper
17
+ def self.map(data)
18
+ {
19
+ id: data[:id],
20
+ model: data[:model],
21
+ usage: data[:usage],
22
+ choices: map_choices(data)
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def self.map_choices(data)
29
+ message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_OUT)
30
+
31
+ content = if data[:content].is_a?(Array)
32
+ data[:content].map do |content|
33
+ message_mapper.map_content(content)
34
+ end
35
+ else
36
+ data[:content] ? [ message_mapper.map_content(data[:content]) ] : []
37
+ end
38
+
39
+ # Claude returns content directly at root level, not in a choices array
40
+ # We need to construct the choices array from the full response data
41
+ [ {
42
+ content: content, # Use content directly from Claude response
43
+ finish_reason: data[:stop_reason],
44
+ role: "assistant"
45
+ } ]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end