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.
- checksums.yaml +4 -4
- data/.pi/skills/live-provider-testing/SKILL.md +183 -0
- data/.pi/skills/options-development/SKILL.md +131 -0
- data/CHANGELOG.md +43 -0
- data/README.md +559 -185
- data/Rakefile +2 -2
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +140 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
- data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
- data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
- data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +62 -1
- data/lib/llm_gateway/client.rb +18 -158
- data/lib/llm_gateway/clients/anthropic.rb +167 -0
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
- data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
- data/lib/llm_gateway/clients/groq.rb +66 -0
- data/lib/llm_gateway/clients/openai.rb +208 -0
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
- data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
- data/lib/llm_gateway/errors.rb +21 -0
- data/lib/llm_gateway/prompt.rb +12 -1
- data/lib/llm_gateway/provider_registry.rb +37 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +162 -17
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- metadata +60 -27
- data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
- data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
- data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
- data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
- data/sample/claude_code_clone/agent.rb +0 -65
- data/sample/claude_code_clone/claude_code_clone.rb +0 -40
- data/sample/claude_code_clone/prompt.rb +0 -79
- data/sample/claude_code_clone/run.rb +0 -47
- data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
- data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
- data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
- data/sample/claude_code_clone/tools/read_tool.rb +0 -61
- 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
|