llm_gateway 0.3.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 +26 -0
  3. data/README.md +544 -186
  4. data/Rakefile +1 -2
  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/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
  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/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
  20. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
  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/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
  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/{open_ai → openai}/file_output_mapper.rb +1 -1
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
  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/{open_ai → openai}/responses/output_mapper.rb +1 -1
  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/option_mapper.rb +13 -0
  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 +62 -1
  41. data/lib/llm_gateway/client.rb +45 -129
  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 +21 -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 +165 -14
  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 -28
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  60. data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -18
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  65. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  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
data/lib/llm_gateway.rb CHANGED
@@ -8,20 +8,42 @@ require_relative "llm_gateway/client"
8
8
  require_relative "llm_gateway/prompt"
9
9
  require_relative "llm_gateway/tool"
10
10
 
11
- # Load adapters - order matters for inheritance
12
- require_relative "llm_gateway/adapters/claude/client"
13
- require_relative "llm_gateway/adapters/claude/input_mapper"
14
- require_relative "llm_gateway/adapters/claude/output_mapper"
15
- require_relative "llm_gateway/adapters/open_ai/client"
16
- require_relative "llm_gateway/adapters/open_ai/file_output_mapper"
17
- require_relative "llm_gateway/adapters/open_ai/chat_completions/input_mapper"
18
- require_relative "llm_gateway/adapters/open_ai/chat_completions/output_mapper"
19
- require_relative "llm_gateway/adapters/groq/client"
20
- require_relative "llm_gateway/adapters/groq/input_mapper"
21
- require_relative "llm_gateway/adapters/groq/output_mapper"
22
- require_relative "llm_gateway/adapters/open_ai/file_output_mapper"
23
- require_relative "llm_gateway/adapters/open_ai/responses/input_mapper"
24
- require_relative "llm_gateway/adapters/open_ai/responses/output_mapper"
11
+ # Load clients - order matters for inheritance
12
+ require_relative "llm_gateway/clients/anthropic"
13
+ require_relative "llm_gateway/clients/claude_code/oauth_flow"
14
+ require_relative "llm_gateway/clients/claude_code/token_manager"
15
+ require_relative "llm_gateway/clients/openai"
16
+ require_relative "llm_gateway/clients/openai_codex/oauth_flow"
17
+ require_relative "llm_gateway/clients/openai_codex/token_manager"
18
+ require_relative "llm_gateway/clients/groq"
19
+
20
+ # Load adapters
21
+ require_relative "llm_gateway/adapters/option_mapper"
22
+ require_relative "llm_gateway/adapters/anthropic_option_mapper"
23
+ require_relative "llm_gateway/adapters/structs"
24
+
25
+ require_relative "llm_gateway/adapters/anthropic/input_mapper"
26
+ require_relative "llm_gateway/adapters/anthropic/output_mapper"
27
+ require_relative "llm_gateway/adapters/openai/file_output_mapper"
28
+ require_relative "llm_gateway/adapters/openai/prompt_cache_option_mapper"
29
+ require_relative "llm_gateway/adapters/openai/chat_completions/input_mapper"
30
+ require_relative "llm_gateway/adapters/openai/chat_completions/output_mapper"
31
+ require_relative "llm_gateway/adapters/openai/chat_completions/option_mapper"
32
+ require_relative "llm_gateway/adapters/openai/file_output_mapper"
33
+ require_relative "llm_gateway/adapters/openai/responses/input_mapper"
34
+ require_relative "llm_gateway/adapters/openai/responses/output_mapper"
35
+ require_relative "llm_gateway/adapters/openai/responses/option_mapper"
36
+
37
+ # Load adapter classes
38
+ require_relative "llm_gateway/adapters/adapter"
39
+ require_relative "llm_gateway/adapters/anthropic/messages_adapter"
40
+ require_relative "llm_gateway/adapters/openai/chat_completions_adapter"
41
+ require_relative "llm_gateway/adapters/openai/responses_adapter"
42
+ require_relative "llm_gateway/adapters/openai_codex/responses_adapter"
43
+ require_relative "llm_gateway/adapters/groq/chat_completions_adapter"
44
+
45
+ # Load provider registry
46
+ require_relative "llm_gateway/provider_registry"
25
47
 
26
48
  module LlmGateway
27
49
  class Error < StandardError; end
@@ -29,4 +51,133 @@ module LlmGateway
29
51
  # Direction constants for message mappers
30
52
  DIRECTION_IN = :in
31
53
  DIRECTION_OUT = :out
54
+
55
+ # Backward-compatible aliases for renamed clients/adapters
56
+ module Clients
57
+ Claude = Anthropic
58
+ OpenAi = OpenAI
59
+ end
60
+
61
+ module Adapters
62
+ module Claude
63
+ Client = LlmGateway::Clients::Anthropic
64
+ MessagesAdapter = LlmGateway::Adapters::Anthropic::MessagesAdapter
65
+ InputMapper = LlmGateway::Adapters::Anthropic::InputMapper
66
+ OutputMapper = LlmGateway::Adapters::Anthropic::OutputMapper
67
+ StreamMapper = LlmGateway::Adapters::Anthropic::StreamMapper
68
+ BidirectionalMessageMapper = LlmGateway::Adapters::Anthropic::BidirectionalMessageMapper
69
+ FileOutputMapper = LlmGateway::Adapters::Anthropic::FileOutputMapper
70
+ end
71
+
72
+ module Anthropic
73
+ Client = LlmGateway::Clients::Anthropic
74
+ end
75
+
76
+ module OpenAI
77
+ Client = LlmGateway::Clients::OpenAI
78
+ end
79
+
80
+ module OpenAi
81
+ Client = LlmGateway::Clients::OpenAI
82
+ ChatCompletionsAdapter = LlmGateway::Adapters::OpenAI::ChatCompletionsAdapter
83
+ ResponsesAdapter = LlmGateway::Adapters::OpenAI::ResponsesAdapter
84
+ PromptCacheOptionMapper = LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
85
+ FileOutputMapper = LlmGateway::Adapters::OpenAI::FileOutputMapper
86
+ ChatCompletions = LlmGateway::Adapters::OpenAI::ChatCompletions
87
+ Responses = LlmGateway::Adapters::OpenAI::Responses
88
+ end
89
+
90
+ module OpenAICodex
91
+ Client = LlmGateway::Clients::OpenAI
92
+ end
93
+
94
+ module OpenAiCodex
95
+ Client = LlmGateway::Clients::OpenAI
96
+ ResponsesAdapter = LlmGateway::Adapters::OpenAICodex::ResponsesAdapter
97
+ InputMapper = LlmGateway::Adapters::OpenAICodex::InputMapper
98
+ OptionMapper = LlmGateway::Adapters::OpenAICodex::OptionMapper
99
+ end
100
+
101
+ module Groq
102
+ Client = LlmGateway::Clients::Groq
103
+ end
104
+ end
105
+
106
+ def self.build_provider(config)
107
+ config = config.transform_keys(&:to_sym)
108
+ provider_name = config.delete(:provider)
109
+ entry = ProviderRegistry.resolve(provider_name)
110
+
111
+ client = entry[:client].new(**config)
112
+ entry[:adapter].new(client)
113
+ end
114
+
115
+ def self.configure(configs)
116
+ @configured_clients ||= {}
117
+
118
+ configs.each do |entry|
119
+ name = entry[:name] || entry["name"]
120
+ config = entry[:config] || entry["config"]
121
+
122
+ raise ArgumentError, "Each config entry must have a :name" unless name
123
+
124
+ client = build_provider(config)
125
+ @configured_clients[name.to_sym] = client
126
+
127
+ define_singleton_method(name.to_sym) { @configured_clients[name.to_sym] }
128
+ end
129
+ end
130
+
131
+ def self.configured_clients
132
+ @configured_clients ||= {}
133
+ end
134
+
135
+ def self.reset_configuration!
136
+ @configured_clients&.each_key do |name|
137
+ singleton_class.remove_method(name) if respond_to?(name)
138
+ end
139
+ @configured_clients = {}
140
+ end
141
+
142
+ # Register built-in providers (canonical keys)
143
+ ProviderRegistry.register("anthropic_messages",
144
+ client: Clients::Anthropic,
145
+ adapter: Adapters::Anthropic::MessagesAdapter)
146
+
147
+ ProviderRegistry.register("openai_completions",
148
+ client: Clients::OpenAI,
149
+ adapter: Adapters::OpenAI::ChatCompletionsAdapter)
150
+
151
+ ProviderRegistry.register("openai_responses",
152
+ client: Clients::OpenAI,
153
+ adapter: Adapters::OpenAI::ResponsesAdapter)
154
+
155
+ ProviderRegistry.register("groq_completions",
156
+ client: Clients::Groq,
157
+ adapter: Adapters::Groq::ChatCompletionsAdapter)
158
+
159
+ ProviderRegistry.register("openai_codex",
160
+ client: Clients::OpenAI,
161
+ adapter: Adapters::OpenAICodex::ResponsesAdapter)
162
+
163
+ # Backward-compatible aliases (deprecated)
164
+ ProviderRegistry.register("anthropic_apikey_messages",
165
+ client: Clients::Anthropic,
166
+ adapter: Adapters::Anthropic::MessagesAdapter)
167
+
168
+ ProviderRegistry.register("openai_apikey_completions",
169
+ client: Clients::OpenAI,
170
+ adapter: Adapters::OpenAI::ChatCompletionsAdapter)
171
+
172
+ ProviderRegistry.register("openai_apikey_responses",
173
+ client: Clients::OpenAI,
174
+ adapter: Adapters::OpenAI::ResponsesAdapter)
175
+
176
+ ProviderRegistry.register("groq_apikey_completions",
177
+ client: Clients::Groq,
178
+ adapter: Adapters::Groq::ChatCompletionsAdapter)
179
+
180
+ ProviderRegistry.register("openai_oauth_codex",
181
+ client: Clients::OpenAI,
182
+ adapter: Adapters::OpenAICodex::ResponsesAdapter)
32
183
  end
@@ -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