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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +544 -186
- data/Rakefile +1 -2
- 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/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
- 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/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
- 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/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
- 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/{open_ai → openai}/file_output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
- 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/{open_ai → openai}/responses/output_mapper.rb +1 -1
- 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/option_mapper.rb +13 -0
- 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 +62 -1
- data/lib/llm_gateway/client.rb +45 -129
- 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 +21 -0
- data/lib/llm_gateway/prompt.rb +12 -1
- data/lib/llm_gateway/provider_registry.rb +37 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +165 -14
- 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 -28
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- 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/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
|
|
12
|
-
require_relative "llm_gateway/
|
|
13
|
-
require_relative "llm_gateway/
|
|
14
|
-
require_relative "llm_gateway/
|
|
15
|
-
require_relative "llm_gateway/
|
|
16
|
-
require_relative "llm_gateway/
|
|
17
|
-
require_relative "llm_gateway/
|
|
18
|
-
require_relative "llm_gateway/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
require_relative "llm_gateway/adapters/
|
|
22
|
-
require_relative "llm_gateway/adapters/
|
|
23
|
-
require_relative "llm_gateway/adapters/
|
|
24
|
-
|
|
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
|