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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +565 -129
- data/Rakefile +8 -3
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +173 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
- data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -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 +39 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
- data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -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 +38 -0
- data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
- data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +97 -1
- data/lib/llm_gateway/client.rb +66 -54
- 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 +54 -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 +23 -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 +169 -10
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- data/scripts/generate_handoff_live_fixture.rb +169 -0
- data/scripts/generate_handoff_media_fixture.rb +167 -0
- metadata +64 -21
- data/lib/llm_gateway/adapters/claude/client.rb +0 -56
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
- data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
- 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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
26
|
+
|
|
24
27
|
content = if msg[:content].is_a?(Array)
|
|
25
28
|
msg[:content].map do |content|
|
|
26
|
-
|
|
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
|
-
|
|
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
|