llm_gateway 0.3.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 +26 -0
  3. data/README.md +544 -186
  4. data/Rakefile +1 -2
  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/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
  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/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
  20. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
  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/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
  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/{open_ai → openai}/file_output_mapper.rb +1 -1
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
  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/{open_ai → openai}/responses/output_mapper.rb +1 -1
  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/option_mapper.rb +13 -0
  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 +62 -1
  41. data/lib/llm_gateway/client.rb +45 -129
  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 +21 -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 +165 -14
  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 -28
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  60. data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -18
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  65. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  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"
@@ -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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module LlmGateway
4
4
  module Adapters
5
- module Claude
5
+ module Anthropic
6
6
  class BidirectionalMessageMapper
7
7
  attr_reader :direction
8
8
 
@@ -25,6 +25,8 @@ module LlmGateway
25
25
  map_tool_use_content(content)
26
26
  when "tool_result"
27
27
  map_tool_result_content(content)
28
+ when "thinking", "reasoning"
29
+ map_reasoning_content(content)
28
30
  else
29
31
  content
30
32
  end
@@ -33,10 +35,12 @@ module LlmGateway
33
35
  private
34
36
 
35
37
  def map_text_content(content)
36
- {
38
+ result = {
37
39
  type: "text",
38
40
  text: content[:text]
39
41
  }
42
+ result[:cache_control] = content[:cache_control] if content[:cache_control]
43
+ result
40
44
  end
41
45
 
42
46
  def map_file_content(content)
@@ -71,12 +75,36 @@ module LlmGateway
71
75
  end
72
76
 
73
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
+
74
85
  {
75
86
  type: "tool_result",
76
87
  tool_use_id: content[:tool_use_id],
77
- content: content[:content]
88
+ content: mapped_content
78
89
  }
79
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
80
108
  end
81
109
  end
82
110
  end
@@ -4,12 +4,11 @@ require_relative "bidirectional_message_mapper"
4
4
 
5
5
  module LlmGateway
6
6
  module Adapters
7
- module Claude
7
+ module Anthropic
8
8
  class InputMapper
9
9
  def self.map(data)
10
10
  {
11
11
  messages: map_messages(data[:messages]),
12
- response_format: data[:response_format],
13
12
  tools: data[:tools],
14
13
  system: map_system(data[:system])
15
14
  }
@@ -45,7 +44,9 @@ module LlmGateway
45
44
  nil
46
45
  elsif system.length == 1 && system.first[:role] == "system"
47
46
  # If we have a single system message, convert to Claude format
48
- [ { 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 ]
49
50
  else
50
51
  # For multiple messages or non-standard format, pass through
51
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module LlmGateway
4
4
  module Adapters
5
- module Claude
5
+ module Anthropic
6
6
  class FileOutputMapper
7
7
  def self.map(data)
8
8
  data.delete(:type) # Didnt see much value in this only option is "file"
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../structs.rb"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module Anthropic
8
+ class StreamMapper
9
+ def map(chunk)
10
+ case chunk[:event]
11
+ when "message_start"
12
+ delta = {
13
+ id: chunk.dig(:data, :message, :id),
14
+ model: chunk.dig(:data, :message, :model),
15
+ role: chunk.dig(:data, :message, :role)
16
+ }
17
+ usage_increment = chunk.dig(:data, :message, :usage) || {}
18
+
19
+ AssistantStreamMessageEvent.new(type: :message_start, usage_increment:, delta:)
20
+ when "content_block_start"
21
+ content_index = chunk.dig(:data, :index)
22
+ delta = chunk.dig(:data, :content_block, :text)
23
+ current_type = chunk.dig(:data, :content_block, :type)
24
+ content_block_types[content_index] = current_type
25
+
26
+ case current_type
27
+ when "thinking"
28
+ AssistantStreamEvent.new(type: :reasoning_start, content_index:, delta:)
29
+ when "text"
30
+ AssistantStreamEvent.new(type: :text_start, content_index:, delta:)
31
+ when "tool_use"
32
+ id = chunk.dig(:data, :content_block, :id)
33
+ name = chunk.dig(:data, :content_block, :name)
34
+ AssistantToolStartEvent.new(type: :tool_start, content_index:, delta:, id:, name:)
35
+ end
36
+ when "content_block_delta"
37
+ content_index = chunk.dig(:data, :index)
38
+
39
+ case content_block_types[content_index]
40
+ when "thinking"
41
+ delta = chunk.dig(:data, :delta, :thinking)
42
+ signature = chunk.dig(:data, :delta, :signature)
43
+ AssistantStreamReasoningEvent.new(type: :reasoning_delta, signature:, delta:, content_index:)
44
+ when "text"
45
+ delta = chunk.dig(:data, :delta, :text)
46
+ AssistantStreamEvent.new(type: :text_delta, content_index:, delta:)
47
+ when "tool_use"
48
+ delta = chunk.dig(:data, :delta, :partial_json)
49
+ AssistantStreamEvent.new(type: :tool_delta, content_index:, delta:)
50
+ end
51
+ when "content_block_stop"
52
+ content_index = chunk.dig(:data, :index)
53
+ type = case content_block_types[content_index]
54
+ when "thinking"
55
+ :reasoning_end
56
+ when "text"
57
+ :text_end
58
+ when "tool_use"
59
+ :tool_end
60
+ end
61
+ AssistantStreamEvent.new(type: type, content_index:, delta: "")
62
+ when "message_delta"
63
+ delta = normalize_message_delta(chunk.dig(:data, :delta) || {})
64
+ usage_increment = chunk.dig(:data, :usage) || {}
65
+
66
+ AssistantStreamMessageEvent.new(type: :message_delta, usage_increment:, delta:)
67
+ when "message_stop"
68
+ AssistantStreamMessageEvent.new(type: :message_end, usage_increment: {}, delta: {})
69
+ when "ping"
70
+ nil
71
+ when "error"
72
+ error = chunk.dig(:data, :error) || {}
73
+ message = error[:message] || "Stream error"
74
+ code = error[:type]
75
+
76
+ if LlmGateway::Errors.context_overflow_message?(message)
77
+ raise LlmGateway::Errors::PromptTooLong.new(message, code)
78
+ end
79
+
80
+ if code == "overloaded_error"
81
+ raise LlmGateway::Errors::OverloadError.new(message, code)
82
+ end
83
+
84
+ raise LlmGateway::Errors::APIStatusError.new(message, code)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def content_block_types
91
+ @content_block_types ||= {}
92
+ end
93
+
94
+ def normalize_message_delta(delta)
95
+ return delta unless delta[:stop_reason] || delta["stop_reason"]
96
+
97
+ stop_reason = delta[:stop_reason] || delta["stop_reason"]
98
+ normalized_stop_reason = case stop_reason
99
+ when "end_turn"
100
+ "stop"
101
+ else
102
+ stop_reason
103
+ end
104
+
105
+ delta.merge(stop_reason: normalized_stop_reason)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module AnthropicOptionMapper
6
+ DEFAULT_MAX_TOKENS = 20_480
7
+ REASONING_EFFORT_BUDGET_TOKENS = {
8
+ "low" => 1024,
9
+ "medium" => 5 * 1024,
10
+ "high" => 10 * 1024,
11
+ "xhigh" => 20 * 1024
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def map(options)
17
+ mapped_options = options.reject { |key, _| %i[reasoning max_completion_tokens response_format prompt_cache_retention cache_key prompt_cache_key].include?(key) }
18
+ mapped_options[:max_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_TOKENS
19
+
20
+ retention = options[:cache_retention]
21
+ mapped_options[:cache_retention] = retention unless retention.nil?
22
+
23
+ response_format = options[:response_format]
24
+ mapped_options[:output_config] = normalize_output_config(response_format) unless response_format.nil?
25
+
26
+ reasoning = options[:reasoning]
27
+ return mapped_options if reasoning.nil? || reasoning.to_s == "none"
28
+
29
+ mapped_options[:thinking] = normalize_reasoning(reasoning)
30
+ mapped_options
31
+ end
32
+
33
+ def normalize_output_config(response_format)
34
+ format_type = response_format.is_a?(Hash) ? response_format[:type] || response_format["type"] : response_format
35
+
36
+ case format_type.to_s
37
+ when "json_object", "json_schema"
38
+ { format: "json_schema" }
39
+ else
40
+ { format: "text" }
41
+ end
42
+ end
43
+
44
+ def normalize_reasoning(reasoning)
45
+ budget_tokens = REASONING_EFFORT_BUDGET_TOKENS[reasoning.to_s] ||
46
+ raise(ArgumentError,
47
+ "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'.")
48
+
49
+ { type: "enabled", budget_tokens: budget_tokens }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../adapter"
4
+ require_relative "../openai/acts_like_chat_completions"
5
+ require_relative "../input_message_sanitizer"
6
+ require_relative "../openai/chat_completions/input_mapper"
7
+ require_relative "option_mapper"
8
+
9
+ module LlmGateway
10
+ module Adapters
11
+ module Groq
12
+ class ChatCompletionsAdapter < Adapter
13
+ include ActsLikeOpenAIChatCompletions
14
+
15
+ private
16
+
17
+ def file_output_mapper = nil
18
+ def stream_mapper = nil
19
+ def option_mapper = Groq::OptionMapper
20
+
21
+ def map_input(input)
22
+ groq_safe_input = input.dup
23
+ groq_safe_input[:messages] = Array(input[:messages]).map do |msg|
24
+ next msg unless msg.is_a?(Hash) && msg[:content].is_a?(Array)
25
+
26
+ rewritten_content = msg[:content].map do |block|
27
+ next block unless block.is_a?(Hash) && block[:type] == "file"
28
+
29
+ {
30
+ type: "text",
31
+ text: block[:text] || "[File: #{block[:name]}]"
32
+ }
33
+ end
34
+
35
+ msg.merge(content: rewritten_content)
36
+ end
37
+
38
+ mapped = super(groq_safe_input)
39
+ mapped[:system] = Array(mapped[:system]).map do |msg|
40
+ msg[:role] == "developer" ? msg.merge(role: "system") : msg
41
+ end
42
+ mapped
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end