llm_gateway 0.4.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 (42) 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 +17 -0
  5. data/README.md +16 -0
  6. data/Rakefile +1 -0
  7. data/lib/llm_gateway/adapters/adapter.rb +2 -35
  8. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +0 -2
  9. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +106 -27
  10. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +0 -33
  11. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -46
  12. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +48 -6
  13. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +3 -2
  14. data/lib/llm_gateway/adapters/groq/input_mapper.rb +44 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +89 -4
  16. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +0 -2
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +0 -6
  19. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +135 -72
  20. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +100 -10
  21. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +169 -170
  22. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +0 -1
  23. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +128 -68
  24. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +99 -10
  25. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +81 -271
  26. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +0 -1
  27. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +3 -3
  28. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +0 -5
  29. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  30. data/lib/llm_gateway/client.rb +10 -66
  31. data/lib/llm_gateway/clients/groq.rb +13 -1
  32. data/lib/llm_gateway/version.rb +1 -1
  33. data/lib/llm_gateway.rb +2 -8
  34. metadata +7 -10
  35. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +0 -111
  36. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +0 -110
  37. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +0 -40
  38. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +0 -120
  39. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +0 -47
  40. data/lib/llm_gateway/adapters/stream_accumulator.rb +0 -91
  41. data/scripts/generate_handoff_live_fixture.rb +0 -169
  42. data/scripts/generate_handoff_media_fixture.rb +0 -167
@@ -1,91 +0,0 @@
1
- require "json"
2
-
3
- class StreamAccumulator
4
- attr_accessor :blocks, :message_hash, :usage_hash
5
-
6
- def initialize
7
- @message_hash = {}
8
- @usage_hash = {
9
- input_tokens: 0,
10
- cache_creation_input_tokens: 0,
11
- cache_read_input_tokens: 0,
12
- output_tokens: 0,
13
- reasoning_tokens: 0
14
- }
15
- @blocks = []
16
- end
17
-
18
- def result
19
- message_hash.merge(
20
- usage: usage_hash,
21
- content: serialized_blocks
22
- )
23
- end
24
-
25
- def push(event)
26
- return unless event
27
-
28
- case event.type
29
- when :text_start
30
- blocks[event.content_index] = {
31
- type: "text",
32
- text: ""
33
- }
34
- blocks[event.content_index][:text] += event.delta
35
- when :text_delta, :text_end
36
- blocks[event.content_index][:text] += event.delta
37
- when :tool_start
38
- blocks[event.content_index] = {
39
- type: "tool_use",
40
- id: event.id,
41
- name: event.name,
42
- input: ""
43
- }
44
- when :tool_delta, :tool_end
45
- blocks[event.content_index][:input] += event.delta
46
- when :message_start
47
- message_hash.merge!(event.delta)
48
- usage_hash.each_key do |key|
49
- usage_hash[key] += event.usage_increment.fetch(key, 0)
50
- end
51
- when :reasoning_start
52
- blocks[event.content_index] = {
53
- type: "reasoning",
54
- reasoning: "",
55
- signature: ""
56
- }
57
- blocks[event.content_index][:reasoning] += event.delta
58
- blocks[event.content_index][:signature] += event.respond_to?(:signature) ? event.signature : ""
59
- when :reasoning_delta
60
- blocks[event.content_index][:reasoning] += event.delta
61
- blocks[event.content_index][:signature] += event.signature
62
- when :reasoning_end
63
- blocks[event.content_index][:reasoning] += event.delta
64
- blocks[event.content_index][:signature] += event.respond_to?(:signature) ? event.signature : ""
65
- when :message_delta
66
- message_hash.merge!(event.delta)
67
- usage_hash.each_key do |key|
68
- usage_hash[key] += event.usage_increment.fetch(key, 0)
69
- end
70
- when :message_end
71
- end
72
- end
73
-
74
- private
75
-
76
- def serialized_blocks
77
- blocks.map do |block|
78
- next block unless block[:type] == "tool_use"
79
-
80
- block.merge(input: LlmGateway::Utils.deep_symbolize_keys(parse_tool_input(block[:input])))
81
- end
82
- end
83
-
84
- def parse_tool_input(input)
85
- return {} if input.nil? || input.empty?
86
-
87
- JSON.parse(input)
88
- rescue JSON::ParserError
89
- {}
90
- end
91
- end
@@ -1,169 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "time"
5
- require "fileutils"
6
- require 'debug'
7
- require_relative "../lib/llm_gateway"
8
- require_relative "../test/utils/calculator_tool_helper"
9
-
10
- class HandoffLiveFixtureGenerator
11
- include CalculatorToolHelper
12
-
13
- PAIRS = [
14
- { provider: "openai_apikey_completions", model: "gpt-5.1" },
15
- { provider: "openai_apikey_responses", model: "gpt-5.4" },
16
- { provider: "openai_oauth_codex", model: "gpt-5.4" },
17
- { provider: "anthropic_apikey_messages", model: "claude-sonnet-4-20250514" }
18
- ].freeze
19
-
20
- FIXTURE_PATH = File.expand_path("../test/fixtures/handoff_live_fixture.json", __dir__)
21
-
22
- def run
23
- flat_transcript = []
24
- skipped = []
25
-
26
- PAIRS.each do |pair|
27
- begin
28
- adapter = load_provider(provider: pair[:provider], model: pair[:model])
29
- mini_context = generate_mini_context(adapter)
30
- flat_transcript.concat(mini_context.map(&:to_h))
31
- puts "[ok] #{pair[:provider]} / #{pair[:model]}"
32
- rescue StandardError => e
33
- skipped << pair.merge(error: e.message)
34
- puts "[skip] #{pair[:provider]} / #{pair[:model]} -> #{e.message}"
35
- ensure
36
- LlmGateway.reset_configuration!
37
- end
38
- end
39
-
40
- FileUtils.mkdir_p(File.dirname(FIXTURE_PATH))
41
- File.write(FIXTURE_PATH, JSON.pretty_generate(flat_transcript) + "\n")
42
-
43
- puts "\nWrote fixture: #{FIXTURE_PATH}"
44
- puts "Messages: #{flat_transcript.length}"
45
- puts "Skipped pairs: #{skipped.length}"
46
- end
47
-
48
- private
49
-
50
- def generate_mini_context(adapter)
51
- transcript = [
52
- {
53
- role: "user",
54
- content: "Think hard about this, then use the math_operation tool to double 21 by multiplying 21 and 2. Call the tool."
55
- }
56
- ]
57
-
58
- first = adapter.stream(transcript, tools: [ math_operation_tool ], reasoning: "high")
59
- raise first.error_message if first.stop_reason == "error"
60
-
61
- transcript << first
62
-
63
- tool_call = first.content.find { |block| block.type == "tool_use" && block.name == "math_operation" }
64
- raise "model did not call math_operation" unless tool_call
65
-
66
- tool_result = {
67
- role: "developer",
68
- content: [
69
- {
70
- type: "tool_result",
71
- tool_use_id: tool_call.id,
72
- content: evaluate_math_operation(tool_call.input).to_s
73
- }
74
- ]
75
- }
76
- transcript << tool_result
77
-
78
- second = adapter.stream(
79
- transcript.map { |m| m.respond_to?(:to_h) ? m.to_h : m },
80
- tools: [ math_operation_tool ],
81
- reasoning: "high"
82
- )
83
- raise second.error_message if second.stop_reason == "error"
84
-
85
- transcript << second
86
- transcript
87
- end
88
-
89
- def load_provider(provider:, model:)
90
- config = {
91
- "provider" => provider,
92
- "model_key" => model
93
- }
94
-
95
- case provider
96
- when "openai_apikey_completions", "openai_apikey_responses"
97
- api_key = ENV["OPENAI_API_KEY"].to_s
98
- raise "missing OPENAI_API_KEY" if api_key.empty?
99
-
100
- config["api_key"] = api_key
101
- when "anthropic_apikey_messages"
102
- api_key = ENV["ANTHROPIC_API_KEY"].to_s
103
- raise "missing ANTHROPIC_API_KEY" if api_key.empty?
104
-
105
- config["api_key"] = api_key
106
- when "openai_oauth_codex"
107
- creds = load_auth_credentials("openai")
108
- config["api_key"] = oauth_access_token_for("openai")
109
- config["account_id"] = creds["account_id"] if creds["account_id"]
110
- else
111
- raise ArgumentError, "Unsupported provider: #{provider}"
112
- end
113
-
114
- LlmGateway.build_provider(config)
115
- end
116
-
117
- def auth_file_path
118
- File.expand_path(ENV.fetch("LLM_GATEWAY_AUTH_FILE", "~/.config/llm_gateway/auth.json"))
119
- end
120
-
121
- def load_auth_credentials(provider)
122
- path = auth_file_path
123
- raise "missing auth file at #{path}" unless File.exist?(path)
124
-
125
- auth = JSON.parse(File.read(path))
126
- creds = auth[provider]
127
- raise "missing #{provider} credentials in #{path}" unless creds
128
-
129
- creds
130
- end
131
-
132
- def persist_auth_credentials(provider, attributes)
133
- path = auth_file_path
134
- FileUtils.mkdir_p(File.dirname(path))
135
-
136
- auth = File.exist?(path) ? JSON.parse(File.read(path)) : {}
137
- auth[provider] ||= {}
138
- auth[provider].merge!(attributes)
139
-
140
- File.write(path, JSON.pretty_generate(auth) + "\n")
141
- end
142
-
143
- def oauth_access_token_for(provider)
144
- creds = load_auth_credentials(provider)
145
-
146
- case provider
147
- when "openai"
148
- token = LlmGateway::Clients::OpenAi.new.get_oauth_access_token(
149
- access_token: creds["access_token"],
150
- refresh_token: creds["refresh_token"],
151
- expires_at: creds["expires_at"],
152
- account_id: creds["account_id"]
153
- ) do |access_token, refresh_token, expires_at|
154
- persist_auth_credentials("openai", {
155
- "access_token" => access_token,
156
- "refresh_token" => refresh_token,
157
- "expires_at" => expires_at&.iso8601
158
- })
159
- end
160
-
161
- persist_auth_credentials("openai", { "access_token" => token }) if token != creds["access_token"]
162
- token
163
- else
164
- raise ArgumentError, "Unsupported OAuth provider: #{provider}"
165
- end
166
- end
167
- end
168
-
169
- HandoffLiveFixtureGenerator.new.run
@@ -1,167 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "time"
5
- require "fileutils"
6
- require_relative "../lib/llm_gateway"
7
- require_relative "../test/utils/readfile_tool_helper"
8
-
9
- class HandoffMediaLiveFixtureGenerator
10
- include ReadfileToolHelper
11
-
12
- PAIRS = [
13
- { provider: "openai_apikey_completions", model: "gpt-5.1" },
14
- { provider: "openai_apikey_responses", model: "gpt-5.4" },
15
- { provider: "openai_oauth_codex", model: "gpt-5.4" },
16
- { provider: "anthropic_apikey_messages", model: "claude-sonnet-4-20250514" }
17
- ].freeze
18
-
19
- FIXTURE_PATH = File.expand_path("../test/fixtures/handoff_media_live_fixture.json", __dir__)
20
-
21
- def run
22
- flat_transcript = []
23
- skipped = []
24
-
25
- PAIRS.each do |pair|
26
- begin
27
- adapter = load_provider(provider: pair[:provider], model: pair[:model])
28
- mini_context = generate_mini_context(adapter)
29
- flat_transcript.concat(mini_context.map(&:to_h))
30
- puts "[ok] #{pair[:provider]} / #{pair[:model]}"
31
- rescue StandardError => e
32
- skipped << pair.merge(error: e.message)
33
- puts "[skip] #{pair[:provider]} / #{pair[:model]} -> #{e.message}"
34
- ensure
35
- LlmGateway.reset_configuration!
36
- end
37
- end
38
-
39
- FileUtils.mkdir_p(File.dirname(FIXTURE_PATH))
40
- File.write(FIXTURE_PATH, JSON.pretty_generate(flat_transcript) + "\n")
41
-
42
- puts "\nWrote fixture: #{FIXTURE_PATH}"
43
- puts "Messages: #{flat_transcript.length}"
44
- puts "Skipped pairs: #{skipped.length}"
45
- end
46
-
47
- private
48
-
49
- def generate_mini_context(adapter)
50
- transcript = [
51
- {
52
- role: "user",
53
- content: "Use the readfile tool with path test/fixtures/red-circle.png and tell me what is in the image."
54
- }
55
- ]
56
-
57
- first = adapter.stream(transcript, tools: [ readfile_tool ], reasoning: "high")
58
- raise first.error_message if first.stop_reason == "error"
59
-
60
- transcript << first
61
-
62
- tool_call = first.content.find { |block| block.type == "tool_use" && block.name == "readfile" }
63
- raise "model did not call readfile" unless tool_call
64
-
65
- transcript << {
66
- role: "developer",
67
- content: [
68
- {
69
- type: "tool_result",
70
- tool_use_id: tool_call.id,
71
- content: evaluate_readfile(tool_call.input)
72
- }
73
- ]
74
- }
75
-
76
- second = adapter.stream(
77
- transcript.map { |m| m.respond_to?(:to_h) ? m.to_h : m },
78
- tools: [ readfile_tool ],
79
- reasoning: "high"
80
- )
81
- raise second.error_message if second.stop_reason == "error"
82
-
83
- transcript << second
84
- transcript
85
- end
86
-
87
- def load_provider(provider:, model:)
88
- config = {
89
- "provider" => provider,
90
- "model_key" => model
91
- }
92
-
93
- case provider
94
- when "openai_apikey_completions", "openai_apikey_responses"
95
- api_key = ENV["OPENAI_API_KEY"].to_s
96
- raise "missing OPENAI_API_KEY" if api_key.empty?
97
-
98
- config["api_key"] = api_key
99
- when "anthropic_apikey_messages"
100
- api_key = ENV["ANTHROPIC_API_KEY"].to_s
101
- raise "missing ANTHROPIC_API_KEY" if api_key.empty?
102
-
103
- config["api_key"] = api_key
104
- when "openai_oauth_codex"
105
- creds = load_auth_credentials("openai")
106
- config["api_key"] = oauth_access_token_for("openai")
107
- config["account_id"] = creds["account_id"] if creds["account_id"]
108
- else
109
- raise ArgumentError, "Unsupported provider: #{provider}"
110
- end
111
-
112
- LlmGateway.build_provider(config)
113
- end
114
-
115
- def auth_file_path
116
- File.expand_path(ENV.fetch("LLM_GATEWAY_AUTH_FILE", "~/.config/llm_gateway/auth.json"))
117
- end
118
-
119
- def load_auth_credentials(provider)
120
- path = auth_file_path
121
- raise "missing auth file at #{path}" unless File.exist?(path)
122
-
123
- auth = JSON.parse(File.read(path))
124
- creds = auth[provider]
125
- raise "missing #{provider} credentials in #{path}" unless creds
126
-
127
- creds
128
- end
129
-
130
- def persist_auth_credentials(provider, attributes)
131
- path = auth_file_path
132
- FileUtils.mkdir_p(File.dirname(path))
133
-
134
- auth = File.exist?(path) ? JSON.parse(File.read(path)) : {}
135
- auth[provider] ||= {}
136
- auth[provider].merge!(attributes)
137
-
138
- File.write(path, JSON.pretty_generate(auth) + "\n")
139
- end
140
-
141
- def oauth_access_token_for(provider)
142
- creds = load_auth_credentials(provider)
143
-
144
- case provider
145
- when "openai"
146
- token = LlmGateway::Clients::OpenAi.new.get_oauth_access_token(
147
- access_token: creds["access_token"],
148
- refresh_token: creds["refresh_token"],
149
- expires_at: creds["expires_at"],
150
- account_id: creds["account_id"]
151
- ) do |access_token, refresh_token, expires_at|
152
- persist_auth_credentials("openai", {
153
- "access_token" => access_token,
154
- "refresh_token" => refresh_token,
155
- "expires_at" => expires_at&.iso8601
156
- })
157
- end
158
-
159
- persist_auth_credentials("openai", { "access_token" => token }) if token != creds["access_token"]
160
- token
161
- else
162
- raise ArgumentError, "Unsupported OAuth provider: #{provider}"
163
- end
164
- end
165
- end
166
-
167
- HandoffMediaLiveFixtureGenerator.new.run