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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +565 -129
  4. data/Rakefile +8 -3
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +97 -1
  41. data/lib/llm_gateway/client.rb +66 -54
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +23 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +169 -10
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -21
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -56
  60. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
  65. data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ require "fileutils"
7
+ require_relative "../lib/llm_gateway"
8
+
9
+ module Scripts
10
+ class CreateAnthropicCredentials
11
+ def initialize(argv)
12
+ @options = {
13
+ client_id: LlmGateway::Clients::ClaudeCode::OAuthFlow::CLIENT_ID,
14
+ scopes: LlmGateway::Clients::ClaudeCode::OAuthFlow::DEFAULT_SCOPES,
15
+ output: File.expand_path(ENV.fetch("LLM_GATEWAY_AUTH_FILE", "~/.config/llm_gateway/auth.json"))
16
+ }
17
+ parse_options(argv)
18
+ end
19
+
20
+ def run
21
+ flow = LlmGateway::Clients::ClaudeCode::OAuthFlow.new(
22
+ client_id: @options[:client_id],
23
+ scopes: @options[:scopes]
24
+ )
25
+
26
+ auth = flow.start
27
+
28
+ puts "Anthropic OAuth setup"
29
+ puts "Redirect URI: #{flow.redirect_uri}"
30
+ puts ""
31
+ puts "Open this URL in your browser:"
32
+ puts auth[:authorization_url]
33
+ puts ""
34
+ puts "After authenticating, paste either:"
35
+ puts "- the full callback URL"
36
+ puts "- or the legacy code#state value"
37
+ print "> "
38
+
39
+ callback_value = $stdin.gets.to_s.strip
40
+ tokens = flow.exchange_code(callback_value, auth[:code_verifier], state: auth[:state])
41
+
42
+ credentials = {
43
+ client_id: flow.client_id,
44
+ access_token: tokens[:access_token],
45
+ refresh_token: tokens[:refresh_token],
46
+ expires_at: tokens[:expires_at]&.iso8601
47
+ }
48
+
49
+ persist_credentials("anthropic", credentials)
50
+
51
+ puts "Credentials:"
52
+ puts JSON.pretty_generate(credentials)
53
+ puts ""
54
+ puts "Environment exports:"
55
+ puts "export ANTHROPIC_ACCESS_TOKEN=#{shell_escape(tokens[:access_token])}"
56
+ puts "export ANTHROPIC_REFRESH_TOKEN=#{shell_escape(tokens[:refresh_token])}"
57
+ puts "export ANTHROPIC_EXPIRES_AT=#{shell_escape(tokens[:expires_at]&.iso8601.to_s)}"
58
+ rescue Interrupt
59
+ warn "Aborted."
60
+ exit 1
61
+ end
62
+
63
+ private
64
+
65
+ def parse_options(argv)
66
+ OptionParser.new do |opts|
67
+ opts.banner = "Usage: ruby scripts/create_anthropic_credentials.rb [options]"
68
+
69
+ opts.on("--client-id ID", "Anthropic OAuth client id") do |value|
70
+ @options[:client_id] = value
71
+ end
72
+
73
+ opts.on("--scopes SCOPES", "OAuth scopes") do |value|
74
+ @options[:scopes] = value
75
+ end
76
+
77
+ opts.on("--output PATH", "Write credentials JSON to PATH") do |value|
78
+ @options[:output] = value
79
+ end
80
+ end.parse!(argv)
81
+ end
82
+
83
+ def persist_credentials(provider, credentials)
84
+ output_path = File.expand_path(@options[:output])
85
+ FileUtils.mkdir_p(File.dirname(output_path))
86
+
87
+ existing = if File.exist?(output_path)
88
+ JSON.parse(File.read(output_path))
89
+ else
90
+ {}
91
+ end
92
+
93
+ existing[provider] = credentials
94
+ File.write(output_path, JSON.pretty_generate(existing) + "\n")
95
+ puts "Credentials written to #{output_path}"
96
+ end
97
+
98
+ def shell_escape(value)
99
+ return "''" if value.nil? || value.empty?
100
+
101
+ "'#{value.to_s.gsub("'", %q('\''))}'"
102
+ end
103
+ end
104
+ end
105
+
106
+ Scripts::CreateAnthropicCredentials.new(ARGV).run
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ require "fileutils"
7
+ require_relative "../lib/llm_gateway"
8
+
9
+ module Scripts
10
+ class CreateOpenAiCodexCredentials
11
+ def initialize(argv)
12
+ @options = {
13
+ client_id: LlmGateway::Clients::OpenAi::OAuthFlow::CLIENT_ID,
14
+ redirect_uri: LlmGateway::Clients::OpenAi::OAuthFlow::REDIRECT_URI,
15
+ scope: LlmGateway::Clients::OpenAi::OAuthFlow::SCOPE,
16
+ output: File.expand_path(ENV.fetch("LLM_GATEWAY_AUTH_FILE", "~/.config/llm_gateway/auth.json"))
17
+ }
18
+ parse_options(argv)
19
+ end
20
+
21
+ def run
22
+ flow = LlmGateway::Clients::OpenAi::OAuthFlow.new(
23
+ client_id: @options[:client_id],
24
+ redirect_uri: @options[:redirect_uri],
25
+ scope: @options[:scope]
26
+ )
27
+
28
+ auth = flow.start
29
+
30
+ puts "OpenAI Codex OAuth setup"
31
+ puts "Redirect URI: #{flow.redirect_uri}"
32
+ puts ""
33
+ puts "Open this URL in your browser:"
34
+ puts auth[:authorization_url]
35
+ puts ""
36
+ puts "After authenticating, the browser will redirect to localhost (the page won't load)."
37
+ puts "Paste either:"
38
+ puts " - the full callback URL (http://localhost:1455/auth/callback?code=...)"
39
+ puts " - or just the code"
40
+ print "> "
41
+
42
+ callback_value = $stdin.gets.to_s.strip
43
+ tokens = flow.exchange_code(callback_value, auth[:code_verifier], expected_state: auth[:state])
44
+
45
+ credentials = {
46
+ client_id: flow.client_id,
47
+ account_id: tokens[:account_id],
48
+ access_token: tokens[:access_token],
49
+ refresh_token: tokens[:refresh_token],
50
+ expires_at: tokens[:expires_at]&.iso8601
51
+ }
52
+
53
+ persist_credentials("openai", credentials)
54
+
55
+ puts ""
56
+ puts "Credentials:"
57
+ puts JSON.pretty_generate(credentials)
58
+ puts ""
59
+ puts "Environment exports:"
60
+ puts "export OPENAI_CODEX_ACCOUNT_ID=#{shell_escape(tokens[:account_id].to_s)}"
61
+ puts "export OPENAI_CODEX_ACCESS_TOKEN=#{shell_escape(tokens[:access_token])}"
62
+ puts "export OPENAI_CODEX_REFRESH_TOKEN=#{shell_escape(tokens[:refresh_token])}"
63
+ puts "export OPENAI_CODEX_EXPIRES_AT=#{shell_escape(tokens[:expires_at]&.iso8601.to_s)}"
64
+ rescue Interrupt
65
+ warn "Aborted."
66
+ exit 1
67
+ end
68
+
69
+ private
70
+
71
+ def parse_options(argv)
72
+ OptionParser.new do |opts|
73
+ opts.banner = "Usage: ruby scripts/create_openai_codex_credentials.rb [options]"
74
+
75
+ opts.on("--client-id ID", "OpenAI OAuth client id") do |value|
76
+ @options[:client_id] = value
77
+ end
78
+
79
+ opts.on("--redirect-uri URI", "OAuth redirect URI") do |value|
80
+ @options[:redirect_uri] = value
81
+ end
82
+
83
+ opts.on("--scope SCOPE", "OAuth scopes (space-separated)") do |value|
84
+ @options[:scope] = value
85
+ end
86
+
87
+ opts.on("--output PATH", "Write credentials JSON to PATH") do |value|
88
+ @options[:output] = value
89
+ end
90
+ end.parse!(argv)
91
+ end
92
+
93
+ def persist_credentials(provider, credentials)
94
+ output_path = File.expand_path(@options[:output])
95
+ FileUtils.mkdir_p(File.dirname(output_path))
96
+
97
+ existing = if File.exist?(output_path)
98
+ JSON.parse(File.read(output_path))
99
+ else
100
+ {}
101
+ end
102
+
103
+ existing[provider] = credentials
104
+ File.write(output_path, JSON.pretty_generate(existing) + "\n")
105
+ puts "Credentials written to #{output_path}"
106
+ end
107
+
108
+ def shell_escape(value)
109
+ return "''" if value.nil? || value.empty?
110
+
111
+ "'#{value.to_s.gsub("'", %q('\''))}'"
112
+ end
113
+ end
114
+ end
115
+
116
+ Scripts::CreateOpenAiCodexCredentials.new(ARGV).run
@@ -0,0 +1,169 @@
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
@@ -0,0 +1,167 @@
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
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_gateway
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - billybonks
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-08-08 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2026-05-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-struct
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  description: LlmGateway provides a consistent Ruby interface for multiple LLM providers
14
28
  including Claude, OpenAI, and Groq. Features include unified response formatting,
15
29
  error handling, and fluent data mapping.
@@ -25,32 +39,61 @@ files:
25
39
  - LICENSE.txt
26
40
  - README.md
27
41
  - Rakefile
42
+ - docs/migration-guide.md
28
43
  - lib/llm_gateway.rb
29
- - lib/llm_gateway/adapters/claude/client.rb
30
- - lib/llm_gateway/adapters/claude/input_mapper.rb
31
- - lib/llm_gateway/adapters/claude/output_mapper.rb
32
- - lib/llm_gateway/adapters/groq/client.rb
33
- - lib/llm_gateway/adapters/groq/input_mapper.rb
34
- - lib/llm_gateway/adapters/groq/output_mapper.rb
35
- - lib/llm_gateway/adapters/open_ai/client.rb
36
- - lib/llm_gateway/adapters/open_ai/input_mapper.rb
37
- - lib/llm_gateway/adapters/open_ai/output_mapper.rb
44
+ - lib/llm_gateway/adapters/adapter.rb
45
+ - lib/llm_gateway/adapters/anthropic/acts_like_messages.rb
46
+ - lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb
47
+ - lib/llm_gateway/adapters/anthropic/input_mapper.rb
48
+ - lib/llm_gateway/adapters/anthropic/messages_adapter.rb
49
+ - lib/llm_gateway/adapters/anthropic/output_mapper.rb
50
+ - lib/llm_gateway/adapters/anthropic/stream_mapper.rb
51
+ - lib/llm_gateway/adapters/anthropic_option_mapper.rb
52
+ - lib/llm_gateway/adapters/groq/chat_completions_adapter.rb
53
+ - lib/llm_gateway/adapters/groq/option_mapper.rb
54
+ - lib/llm_gateway/adapters/input_message_sanitizer.rb
55
+ - lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb
56
+ - lib/llm_gateway/adapters/openai/acts_like_responses.rb
57
+ - lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb
58
+ - lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb
59
+ - lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb
60
+ - lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb
61
+ - lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb
62
+ - lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb
63
+ - lib/llm_gateway/adapters/openai/chat_completions_adapter.rb
64
+ - lib/llm_gateway/adapters/openai/file_output_mapper.rb
65
+ - lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb
66
+ - lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb
67
+ - lib/llm_gateway/adapters/openai/responses/input_mapper.rb
68
+ - lib/llm_gateway/adapters/openai/responses/option_mapper.rb
69
+ - lib/llm_gateway/adapters/openai/responses/output_mapper.rb
70
+ - lib/llm_gateway/adapters/openai/responses/stream_mapper.rb
71
+ - lib/llm_gateway/adapters/openai/responses_adapter.rb
72
+ - lib/llm_gateway/adapters/openai_codex/input_mapper.rb
73
+ - lib/llm_gateway/adapters/openai_codex/option_mapper.rb
74
+ - lib/llm_gateway/adapters/openai_codex/responses_adapter.rb
75
+ - lib/llm_gateway/adapters/option_mapper.rb
76
+ - lib/llm_gateway/adapters/stream_accumulator.rb
77
+ - lib/llm_gateway/adapters/structs.rb
38
78
  - lib/llm_gateway/base_client.rb
39
79
  - lib/llm_gateway/client.rb
80
+ - lib/llm_gateway/clients/anthropic.rb
81
+ - lib/llm_gateway/clients/claude_code/oauth_flow.rb
82
+ - lib/llm_gateway/clients/claude_code/token_manager.rb
83
+ - lib/llm_gateway/clients/groq.rb
84
+ - lib/llm_gateway/clients/openai.rb
85
+ - lib/llm_gateway/clients/openai_codex/oauth_flow.rb
86
+ - lib/llm_gateway/clients/openai_codex/token_manager.rb
40
87
  - lib/llm_gateway/errors.rb
41
88
  - lib/llm_gateway/prompt.rb
89
+ - lib/llm_gateway/provider_registry.rb
42
90
  - lib/llm_gateway/tool.rb
43
91
  - lib/llm_gateway/utils.rb
44
92
  - lib/llm_gateway/version.rb
45
- - sample/claude_code_clone/agent.rb
46
- - sample/claude_code_clone/claude_code_clone.rb
47
- - sample/claude_code_clone/prompt.rb
48
- - sample/claude_code_clone/run.rb
49
- - sample/claude_code_clone/tools/bash_tool.rb
50
- - sample/claude_code_clone/tools/edit_tool.rb
51
- - sample/claude_code_clone/tools/grep_tool.rb
52
- - sample/claude_code_clone/tools/read_tool.rb
53
- - sample/claude_code_clone/tools/todowrite_tool.rb
93
+ - scripts/create_anthropic_credentials.rb
94
+ - scripts/create_openai_codex_credentials.rb
95
+ - scripts/generate_handoff_live_fixture.rb
96
+ - scripts/generate_handoff_media_fixture.rb
54
97
  - sig/llm_gateway.rbs
55
98
  homepage: https://github.com/Hyper-Unearthing/llm_gateway
56
99
  licenses: