llm_gateway 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.pi/skills/live-provider-testing/SKILL.md +183 -0
  3. data/.pi/skills/options-development/SKILL.md +131 -0
  4. data/CHANGELOG.md +43 -0
  5. data/README.md +559 -185
  6. data/Rakefile +2 -2
  7. data/docs/migration-guide.md +135 -0
  8. data/lib/llm_gateway/adapters/adapter.rb +140 -0
  9. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
  10. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
  11. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  12. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
  13. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
  14. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
  15. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
  16. data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
  17. data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
  18. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  19. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  20. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
  21. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
  26. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
  27. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  28. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
  31. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
  32. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
  33. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  34. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  35. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
  36. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  37. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  38. data/lib/llm_gateway/adapters/structs.rb +145 -0
  39. data/lib/llm_gateway/base_client.rb +62 -1
  40. data/lib/llm_gateway/client.rb +18 -158
  41. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  42. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  43. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  44. data/lib/llm_gateway/clients/groq.rb +66 -0
  45. data/lib/llm_gateway/clients/openai.rb +208 -0
  46. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  47. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  48. data/lib/llm_gateway/errors.rb +21 -0
  49. data/lib/llm_gateway/prompt.rb +12 -1
  50. data/lib/llm_gateway/provider_registry.rb +37 -0
  51. data/lib/llm_gateway/version.rb +1 -1
  52. data/lib/llm_gateway.rb +162 -17
  53. data/scripts/create_anthropic_credentials.rb +106 -0
  54. data/scripts/create_openai_codex_credentials.rb +116 -0
  55. metadata +60 -27
  56. data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
  57. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  58. data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
  59. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
  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/output_mapper.rb +0 -10
  63. data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
  64. data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
  65. data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
  66. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  67. data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
  68. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  69. data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
  70. data/sample/claude_code_clone/agent.rb +0 -65
  71. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  72. data/sample/claude_code_clone/prompt.rb +0 -79
  73. data/sample/claude_code_clone/run.rb +0 -47
  74. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  75. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  76. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  77. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  78. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
data/Rakefile CHANGED
@@ -3,6 +3,7 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
5
 
6
+ ENV["LLM_GATEWAY_DELETE_UNUSED_VCR_CASSETTES"] ||= "1"
6
7
  Minitest::TestTask.create
7
8
 
8
9
  require "rubocop/rake_task"
@@ -10,10 +11,9 @@ require "rubocop/rake_task"
10
11
  RuboCop::RakeTask.new
11
12
 
12
13
  begin
13
- require "gem/release"
14
-
15
14
  desc "Release with changelog"
16
15
  task :gem_release do
16
+ require "gem/release"
17
17
  # Safety checks: ensure we're on main and up-to-date
18
18
  current_branch = `git branch --show-current`.strip
19
19
  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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "structs"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ class Adapter
8
+ attr_reader :client
9
+
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ def stream(message, tools: nil, system: nil, **options, &block)
15
+ raise LlmGateway::Errors::MissingMapperForProvider, "No stream_mapper configured" unless stream_mapper
16
+
17
+ normalized_input = map_input({
18
+ messages: sanitize_messages(normalize_messages(message)),
19
+ tools: tools,
20
+ system: normalize_system(system)
21
+ })
22
+
23
+ mapper = stream_mapper.new
24
+
25
+ perform_stream(
26
+ normalized_input[:messages],
27
+ tools: normalized_input[:tools],
28
+ system: normalized_input[:system],
29
+ **map_options(options)
30
+ ) do |chunk|
31
+ mapper.map(chunk, &block)
32
+ end
33
+
34
+ AssistantMessage.new(
35
+ mapper.result.merge(
36
+ provider: LlmGateway::Client.provider_id_from_client(client),
37
+ api: api_name
38
+ )
39
+ )
40
+ end
41
+
42
+ def upload_file(filename:, content:, mime_type: "application/octet-stream", purpose: "assistants")
43
+ raise LlmGateway::Errors::MissingMapperForProvider, "No file_output_mapper configured" unless file_output_mapper
44
+
45
+ upload_params = client.method(:upload_file).parameters
46
+ supports_purpose = upload_params.any? { |type, name| [ :key, :keyreq ].include?(type) && name == :purpose }
47
+
48
+ result = if supports_purpose
49
+ client.upload_file(filename, content, mime_type, purpose: purpose)
50
+ else
51
+ client.upload_file(filename, content, mime_type)
52
+ end
53
+
54
+ file_output_mapper.map(result)
55
+ end
56
+
57
+ def download_file(file_id:)
58
+ raise LlmGateway::Errors::MissingMapperForProvider, "No file_output_mapper configured" unless file_output_mapper
59
+
60
+ result = client.download_file(file_id)
61
+ file_output_mapper.map(result)
62
+ end
63
+
64
+ private
65
+
66
+ def input_mapper
67
+ raise NotImplementedError, "#{self.class} must implement #input_mapper"
68
+ end
69
+
70
+ def input_sanitizer
71
+ nil
72
+ end
73
+
74
+ def file_output_mapper
75
+ nil
76
+ end
77
+
78
+ def option_mapper
79
+ OptionMapper
80
+ end
81
+
82
+ def map_input(input)
83
+ input_mapper.map(input)
84
+ end
85
+
86
+ def map_options(options)
87
+ option_mapper.map(options)
88
+ end
89
+
90
+ def perform_stream(messages, tools:, system:, **options, &block)
91
+ client.stream(messages, tools: tools, system: system, **options, &block)
92
+ end
93
+
94
+ def api_name
95
+ self.class.name.split("::").last.gsub(/Adapter$/, "").downcase
96
+ end
97
+
98
+ def stream_mapper
99
+ nil
100
+ end
101
+
102
+ def sanitize_messages(messages)
103
+ return messages unless input_sanitizer
104
+
105
+ target_provider = LlmGateway::Client.provider_id_from_client(client)
106
+ target_api = api_name
107
+ target_model = client.model_key
108
+
109
+ return messages if target_provider.nil? || target_api.nil? || target_model.nil?
110
+
111
+ input_sanitizer.sanitize(
112
+ messages,
113
+ target_provider: target_provider,
114
+ target_api: target_api,
115
+ target_model: target_model
116
+ )
117
+ end
118
+
119
+ def normalize_system(system)
120
+ if system.nil?
121
+ []
122
+ elsif system.is_a?(String)
123
+ [ { role: "system", content: system } ]
124
+ elsif system.is_a?(Array)
125
+ system
126
+ else
127
+ raise ArgumentError, "System parameter must be a string or array, got #{system.class}"
128
+ end
129
+ end
130
+
131
+ def normalize_messages(message)
132
+ if message.is_a?(String)
133
+ [ { role: "user", content: message } ]
134
+ else
135
+ message
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,21 @@
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 file_output_mapper = Anthropic::FileOutputMapper
15
+
16
+ def option_mapper = AnthropicOptionMapper
17
+
18
+ def stream_mapper = Anthropic::StreamMapper
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module Anthropic
6
+ class InputMapper
7
+ def self.map(data)
8
+ {
9
+ messages: map_messages(data[:messages]),
10
+ tools: data[:tools],
11
+ system: map_system(data[:system])
12
+ }
13
+ end
14
+
15
+ def self.map_content(content)
16
+ content = { type: "text", text: content } unless content.is_a?(Hash)
17
+
18
+ case content[:type]
19
+ when "text"
20
+ map_text_content(content)
21
+ when "file"
22
+ map_file_content(content)
23
+ when "image"
24
+ map_image_content(content)
25
+ when "tool_use"
26
+ map_tool_use_content(content)
27
+ when "tool_result"
28
+ map_tool_result_content(content)
29
+ when "thinking", "reasoning"
30
+ map_reasoning_content(content)
31
+ else
32
+ content
33
+ end
34
+ end
35
+
36
+ class << self
37
+ private
38
+
39
+ def map_messages(messages)
40
+ return messages unless messages
41
+
42
+ messages.map do |msg|
43
+ msg = msg.merge(role: "user") if msg[:role] == "developer"
44
+
45
+ content = if msg[:content].is_a?(Array)
46
+ msg[:content].map { |content| map_content(content) }
47
+ else
48
+ [ map_content(msg[:content]) ]
49
+ end
50
+
51
+ {
52
+ role: msg[:role],
53
+ content: content
54
+ }
55
+ end
56
+ end
57
+
58
+ def map_system(system)
59
+ if !system || system.empty?
60
+ nil
61
+ elsif system.length == 1 && system.first[:role] == "system"
62
+ mapped = { type: "text", text: system.first[:content] }
63
+ mapped[:cache_control] = system.first[:cache_control] if system.first[:cache_control]
64
+ [ mapped ]
65
+ else
66
+ system
67
+ end
68
+ end
69
+
70
+ def map_text_content(content)
71
+ result = {
72
+ type: "text",
73
+ text: content[:text]
74
+ }
75
+ result[:cache_control] = content[:cache_control] if content[:cache_control]
76
+ result
77
+ end
78
+
79
+ def map_file_content(content)
80
+ {
81
+ type: "document",
82
+ source: {
83
+ data: content[:data],
84
+ type: "text",
85
+ media_type: content[:media_type]
86
+ }
87
+ }
88
+ end
89
+
90
+ def map_image_content(content)
91
+ {
92
+ type: "image",
93
+ source: {
94
+ data: content[:data],
95
+ type: "base64",
96
+ media_type: content[:media_type]
97
+ }
98
+ }
99
+ end
100
+
101
+ def map_tool_use_content(content)
102
+ {
103
+ type: "tool_use",
104
+ id: content[:id],
105
+ name: content[:name],
106
+ input: content[:input]
107
+ }
108
+ end
109
+
110
+ def map_tool_result_content(content)
111
+ mapped_content = content[:content]
112
+ if mapped_content.is_a?(Array)
113
+ mapped_content = mapped_content.map do |item|
114
+ item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
115
+ end
116
+ end
117
+
118
+ {
119
+ type: "tool_result",
120
+ tool_use_id: content[:tool_use_id],
121
+ content: mapped_content
122
+ }
123
+ end
124
+
125
+ def map_reasoning_content(content)
126
+ result = {
127
+ type: "thinking",
128
+ thinking: content[:reasoning]
129
+ }
130
+ result[:signature] = content[:signature] unless content[:signature].nil?
131
+ result
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -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,17 @@
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
+ end
16
+ end
17
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../stream_mapper"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module Anthropic
8
+ class StreamMapper < LlmGateway::Adapters::StreamMapper
9
+ def map(chunk, &block)
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
+ accumulator.push({ type: :message_start, usage_increment:, delta: }, &block)
20
+ when "content_block_start"
21
+ content_block = chunk.dig(:data, :content_block) || {}
22
+ @current_content_block_type = content_block[:type]
23
+
24
+ case @current_content_block_type
25
+ when "thinking"
26
+ accumulator.push({ type: :reasoning_start, delta: content_block[:thinking], signature: "" }, &block)
27
+ when "text"
28
+ accumulator.push({ type: :text_start, delta: content_block[:text] }, &block)
29
+ when "tool_use"
30
+ accumulator.push(
31
+ {
32
+ type: :tool_start,
33
+ delta: "",
34
+ id: content_block[:id],
35
+ name: content_block[:name]
36
+ },
37
+ &block
38
+ )
39
+ end
40
+ when "content_block_delta"
41
+ case @current_content_block_type
42
+ when "thinking"
43
+ delta = chunk.dig(:data, :delta, :thinking)
44
+ signature = chunk.dig(:data, :delta, :signature) || ""
45
+ accumulator.push({ type: :reasoning_delta, signature:, delta: }, &block)
46
+ when "text"
47
+ delta = chunk.dig(:data, :delta, :text)
48
+ accumulator.push({ type: :text_delta, delta: }, &block)
49
+ when "tool_use"
50
+ delta = chunk.dig(:data, :delta, :partial_json)
51
+ accumulator.push({ type: :tool_delta, delta: }, &block)
52
+ end
53
+ when "content_block_stop"
54
+ case @current_content_block_type
55
+ when "thinking"
56
+ accumulator.push({ type: :reasoning_end, delta: "", signature: "" }, &block)
57
+ when "text"
58
+ accumulator.push({ type: :text_end, delta: "" }, &block)
59
+ when "tool_use"
60
+ accumulator.push({ type: :tool_end, delta: "" }, &block)
61
+ end
62
+ @current_content_block_type = nil
63
+ when "message_delta"
64
+ delta = normalize_message_delta(chunk.dig(:data, :delta) || {})
65
+ usage_increment = chunk.dig(:data, :usage) || {}
66
+
67
+ accumulator.push({ type: :message_delta, usage_increment:, delta: }, &block)
68
+ when "message_stop"
69
+ accumulator.push({ type: :message_end }, &block)
70
+ when "ping"
71
+ nil
72
+ when "error"
73
+ raise_stream_error!(chunk.dig(:data, :error) || {}, overload_codes: [ "overloaded_error" ])
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def normalize_message_delta(delta)
80
+ return delta unless delta[:stop_reason] || delta["stop_reason"]
81
+
82
+ stop_reason = delta[:stop_reason] || delta["stop_reason"]
83
+ normalized_stop_reason = case stop_reason
84
+ when "end_turn"
85
+ "stop"
86
+ else
87
+ stop_reason
88
+ end
89
+
90
+ delta.merge(stop_reason: normalized_stop_reason)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,95 @@
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
+ # Source: https://platform.claude.com/docs/en/api/messages/create.md
15
+ # API: Anthropic Messages Create; accessed 2026-05-18.
16
+ # Body parameters listed by the API reference: max_tokens, messages, model,
17
+ # cache_control, container, inference_geo, metadata, output_config,
18
+ # service_tier, stop_sequences, stream, system, temperature, thinking,
19
+ # tool_choice, tools, top_k, top_p.
20
+ # This mapper intentionally excludes transcript/tool/system structural fields
21
+ # (messages, system, tool_choice, tools) from option handling.
22
+
23
+ VALID_OPTIONS = %i[
24
+ max_tokens
25
+ model
26
+ cache_control
27
+ cache_retention
28
+ container
29
+ inference_geo
30
+ metadata
31
+ output_config
32
+ service_tier
33
+ stop_sequences
34
+ stream
35
+ temperature
36
+ thinking
37
+ top_k
38
+ top_p
39
+ ].freeze
40
+
41
+ MANAGED_OPTIONS = %i[
42
+ reasoning
43
+ max_completion_tokens
44
+ response_format
45
+ cache_key
46
+ prompt_cache_key
47
+ prompt_cache_retention
48
+ ].freeze
49
+
50
+ module_function
51
+
52
+ def map(options)
53
+ mapped_options = options.reject { |key, _| MANAGED_OPTIONS.include?(key) }
54
+ mapped_options[:max_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_TOKENS
55
+
56
+ response_format = options[:response_format]
57
+ mapped_options[:output_config] = normalize_output_config(response_format) unless response_format.nil?
58
+
59
+ reasoning = options[:reasoning]
60
+ mapped_options[:thinking] = normalize_reasoning(reasoning) unless reasoning.nil? || reasoning.to_s == "none"
61
+
62
+ validate_options!(mapped_options)
63
+ mapped_options
64
+ end
65
+
66
+ def validate_options!(mapped_options)
67
+ unknown_options = mapped_options.keys - VALID_OPTIONS
68
+ return if unknown_options.empty?
69
+
70
+ raise ArgumentError,
71
+ "Unknown Anthropic Messages options: #{unknown_options.join(', ')}. " \
72
+ "Valid options: #{VALID_OPTIONS.join(', ')}."
73
+ end
74
+
75
+ def normalize_output_config(response_format)
76
+ format_type = response_format.is_a?(Hash) ? response_format[:type] || response_format["type"] : response_format
77
+
78
+ case format_type.to_s
79
+ when "json_object", "json_schema"
80
+ { format: "json_schema" }
81
+ else
82
+ { format: "text" }
83
+ end
84
+ end
85
+
86
+ def normalize_reasoning(reasoning)
87
+ budget_tokens = REASONING_EFFORT_BUDGET_TOKENS[reasoning.to_s] ||
88
+ raise(ArgumentError,
89
+ "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'.")
90
+
91
+ { type: "enabled", budget_tokens: budget_tokens }
92
+ end
93
+ end
94
+ end
95
+ end