llm_gateway 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +565 -129
- data/Rakefile +8 -3
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +173 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
- data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
- data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
- data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
- data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
- data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +97 -1
- data/lib/llm_gateway/client.rb +66 -54
- data/lib/llm_gateway/clients/anthropic.rb +167 -0
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
- data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
- data/lib/llm_gateway/clients/groq.rb +54 -0
- data/lib/llm_gateway/clients/openai.rb +208 -0
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
- data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
- data/lib/llm_gateway/errors.rb +23 -0
- data/lib/llm_gateway/prompt.rb +12 -1
- data/lib/llm_gateway/provider_registry.rb +37 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +169 -10
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- data/scripts/generate_handoff_live_fixture.rb +169 -0
- data/scripts/generate_handoff_media_fixture.rb +167 -0
- metadata +64 -21
- data/lib/llm_gateway/adapters/claude/client.rb +0 -56
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
- data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
- data/sample/claude_code_clone/agent.rb +0 -65
- data/sample/claude_code_clone/claude_code_clone.rb +0 -40
- data/sample/claude_code_clone/prompt.rb +0 -79
- data/sample/claude_code_clone/run.rb +0 -47
- data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
- data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
- data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
- data/sample/claude_code_clone/tools/read_tool.rb +0 -61
- data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
|
@@ -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.
|
|
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:
|
|
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/
|
|
30
|
-
- lib/llm_gateway/adapters/
|
|
31
|
-
- lib/llm_gateway/adapters/
|
|
32
|
-
- lib/llm_gateway/adapters/
|
|
33
|
-
- lib/llm_gateway/adapters/
|
|
34
|
-
- lib/llm_gateway/adapters/
|
|
35
|
-
- lib/llm_gateway/adapters/
|
|
36
|
-
- lib/llm_gateway/adapters/
|
|
37
|
-
- lib/llm_gateway/adapters/
|
|
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
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
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:
|