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/client.rb
CHANGED
|
@@ -2,107 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmGateway
|
|
4
4
|
class Client
|
|
5
|
-
def self.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
input_mapper: LlmGateway::Adapters::Claude::InputMapper,
|
|
9
|
-
output_mapper: LlmGateway::Adapters::Claude::OutputMapper,
|
|
10
|
-
client: LlmGateway::Adapters::Claude::Client,
|
|
11
|
-
file_output_mapper: LlmGateway::Adapters::Claude::FileOutputMapper
|
|
12
|
-
},
|
|
13
|
-
openai: {
|
|
14
|
-
input_mapper: LlmGateway::Adapters::OpenAi::ChatCompletions::InputMapper,
|
|
15
|
-
output_mapper: LlmGateway::Adapters::OpenAi::ChatCompletions::OutputMapper,
|
|
16
|
-
client: LlmGateway::Adapters::OpenAi::Client,
|
|
17
|
-
file_output_mapper: LlmGateway::Adapters::OpenAi::FileOutputMapper
|
|
18
|
-
},
|
|
19
|
-
openai_responses: {
|
|
20
|
-
input_mapper: LlmGateway::Adapters::OpenAi::Responses::InputMapper,
|
|
21
|
-
output_mapper: LlmGateway::Adapters::OpenAi::Responses::OutputMapper,
|
|
22
|
-
client: LlmGateway::Adapters::OpenAi::Client,
|
|
23
|
-
file_output_mapper: LlmGateway::Adapters::OpenAi::FileOutputMapper
|
|
24
|
-
},
|
|
25
|
-
groq: {
|
|
26
|
-
input_mapper: LlmGateway::Adapters::Groq::InputMapper,
|
|
27
|
-
output_mapper: LlmGateway::Adapters::Groq::OutputMapper,
|
|
28
|
-
client: LlmGateway::Adapters::Groq::Client,
|
|
29
|
-
file_output_mapper: nil
|
|
30
|
-
}
|
|
31
|
-
}.freeze
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def self.get_provider_config(provider_id)
|
|
35
|
-
provider_configs[provider_id.to_sym] || raise(LlmGateway::Errors::UnsupportedProvider, provider_id)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def self.chat(model, message, response_format: "text", tools: nil, system: nil, api_key: nil)
|
|
39
|
-
provider = provider_from_model(model)
|
|
40
|
-
config = get_provider_config(provider)
|
|
41
|
-
client_options = { model_key: model }
|
|
42
|
-
client_options[:api_key] = api_key if api_key
|
|
43
|
-
client = config[:client].new(**client_options)
|
|
44
|
-
|
|
45
|
-
input_mapper = input_mapper_for_client(client)
|
|
46
|
-
normalized_input = input_mapper.map({
|
|
47
|
-
messages: normalize_messages(message),
|
|
48
|
-
response_format: normalize_response_format(response_format),
|
|
49
|
-
tools: tools,
|
|
50
|
-
system: normalize_system(system)
|
|
51
|
-
})
|
|
52
|
-
result = client.chat(
|
|
53
|
-
normalized_input[:messages],
|
|
54
|
-
response_format: normalized_input[:response_format],
|
|
55
|
-
tools: normalized_input[:tools],
|
|
56
|
-
system: normalized_input[:system]
|
|
57
|
-
)
|
|
58
|
-
result_mapper(client).map(result)
|
|
5
|
+
def self.chat(model, message, tools: nil, system: nil, api_key: nil, refresh_token: nil, expires_at: nil, **options)
|
|
6
|
+
adapter = build_adapter_from_model(model, api_key: api_key, refresh_token: refresh_token, expires_at: expires_at)
|
|
7
|
+
adapter.chat(message, tools: tools, system: system, **options)
|
|
59
8
|
end
|
|
60
9
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
config = provider == "openai" ? get_provider_config("openai_responses") : get_provider_config(provider)
|
|
65
|
-
client_options = { model_key: model }
|
|
66
|
-
client_options[:api_key] = api_key if api_key
|
|
67
|
-
client = config[:client].new(**client_options)
|
|
68
|
-
input_mapper = config[:input_mapper]
|
|
69
|
-
normalized_input = input_mapper.map({
|
|
70
|
-
messages: normalize_messages(message),
|
|
71
|
-
response_format: normalize_response_format(response_format),
|
|
72
|
-
tools: tools,
|
|
73
|
-
system: normalize_system(system)
|
|
74
|
-
})
|
|
75
|
-
method = provider == "openai" ? "responses" : "chat"
|
|
76
|
-
result = client.send(method,
|
|
77
|
-
normalized_input[:messages],
|
|
78
|
-
response_format: normalized_input[:response_format],
|
|
79
|
-
tools: normalized_input[:tools],
|
|
80
|
-
system: normalized_input[:system]
|
|
81
|
-
)
|
|
82
|
-
config[:output_mapper].map(result)
|
|
10
|
+
def self.responses(model, message, tools: nil, system: nil, api_key: nil, **options)
|
|
11
|
+
adapter = build_adapter_from_model(model, api_key: api_key, api: "responses")
|
|
12
|
+
adapter.chat(message, tools: tools, system: system, **options)
|
|
83
13
|
end
|
|
84
14
|
|
|
85
15
|
def self.build_client(provider, api_key:, model: "none")
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
16
|
+
adapter = LlmGateway.build_provider(
|
|
17
|
+
provider: provider,
|
|
18
|
+
api_key: api_key,
|
|
19
|
+
model_key: model
|
|
20
|
+
)
|
|
21
|
+
adapter.client
|
|
90
22
|
end
|
|
91
23
|
|
|
92
24
|
def self.upload_file(provider, **kwargs)
|
|
93
25
|
api_key = kwargs.delete(:api_key)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
26
|
+
adapter = LlmGateway.build_provider(
|
|
27
|
+
provider: provider,
|
|
28
|
+
api_key: api_key
|
|
29
|
+
)
|
|
30
|
+
adapter.upload_file(**kwargs)
|
|
98
31
|
end
|
|
99
32
|
|
|
100
33
|
def self.download_file(provider, **kwargs)
|
|
101
34
|
api_key = kwargs.delete(:api_key)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
35
|
+
adapter = LlmGateway.build_provider(
|
|
36
|
+
provider: provider,
|
|
37
|
+
api_key: api_key
|
|
38
|
+
)
|
|
39
|
+
adapter.download_file(**kwargs)
|
|
106
40
|
end
|
|
107
41
|
|
|
108
42
|
def self.provider_from_model(model)
|
|
@@ -115,61 +49,43 @@ module LlmGateway
|
|
|
115
49
|
raise LlmGateway::Errors::UnsupportedModel, model
|
|
116
50
|
end
|
|
117
51
|
|
|
118
|
-
|
|
119
|
-
def self.input_mapper_for_client(client)
|
|
120
|
-
config = get_provider_config_by_client(client)
|
|
121
|
-
config[:input_mapper]
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def self.result_mapper(client)
|
|
125
|
-
config = get_provider_config_by_client(client)
|
|
126
|
-
config[:output_mapper]
|
|
127
|
-
end
|
|
128
|
-
|
|
129
52
|
def self.provider_id_from_client(client)
|
|
130
53
|
case client
|
|
131
|
-
when LlmGateway::
|
|
54
|
+
when LlmGateway::Clients::Anthropic
|
|
132
55
|
"anthropic"
|
|
133
|
-
when LlmGateway::
|
|
56
|
+
when LlmGateway::Clients::OpenAI
|
|
134
57
|
"openai"
|
|
135
|
-
when LlmGateway::
|
|
58
|
+
when LlmGateway::Clients::Groq
|
|
136
59
|
"groq"
|
|
137
60
|
else
|
|
138
|
-
|
|
61
|
+
client.class.name.downcase
|
|
139
62
|
end
|
|
140
63
|
end
|
|
141
64
|
|
|
142
|
-
|
|
143
|
-
provider_id = provider_id_from_client(client)
|
|
144
|
-
get_provider_config(provider_id)
|
|
145
|
-
end
|
|
65
|
+
# --- private helpers ---
|
|
146
66
|
|
|
147
|
-
def self.
|
|
148
|
-
|
|
149
|
-
[]
|
|
150
|
-
elsif system.is_a?(String)
|
|
151
|
-
[ { role: "system", content: system } ]
|
|
152
|
-
elsif system.is_a?(Array)
|
|
153
|
-
system
|
|
154
|
-
else
|
|
155
|
-
raise ArgumentError, "System parameter must be a string or array, got #{system.class}"
|
|
156
|
-
end
|
|
157
|
-
end
|
|
67
|
+
def self.build_adapter_from_model(model, api_key: nil, refresh_token: nil, expires_at: nil, api: nil)
|
|
68
|
+
provider = provider_from_model(model)
|
|
158
69
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
70
|
+
if api == "responses"
|
|
71
|
+
config = {
|
|
72
|
+
provider: "#{provider}_responses",
|
|
73
|
+
model_key: model
|
|
74
|
+
}
|
|
75
|
+
config[:api_key] = api_key if api_key
|
|
76
|
+
LlmGateway.build_provider(config)
|
|
162
77
|
else
|
|
163
|
-
|
|
78
|
+
provider_key = case provider
|
|
79
|
+
when "anthropic" then "anthropic_messages"
|
|
80
|
+
when "openai" then "openai_completions"
|
|
81
|
+
when "groq" then "groq_completions"
|
|
82
|
+
end
|
|
83
|
+
config = { provider: provider_key, model_key: model }
|
|
84
|
+
config[:api_key] = api_key if api_key
|
|
85
|
+
LlmGateway.build_provider(config)
|
|
164
86
|
end
|
|
165
87
|
end
|
|
166
88
|
|
|
167
|
-
|
|
168
|
-
if response_format.is_a?(String)
|
|
169
|
-
{ type: response_format }
|
|
170
|
-
else
|
|
171
|
-
response_format
|
|
172
|
-
end
|
|
173
|
-
end
|
|
89
|
+
private_class_method :build_adapter_from_model
|
|
174
90
|
end
|
|
175
91
|
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../base_client"
|
|
5
|
+
require_relative "claude_code/oauth_flow"
|
|
6
|
+
require_relative "claude_code/token_manager"
|
|
7
|
+
|
|
8
|
+
module LlmGateway
|
|
9
|
+
module Clients
|
|
10
|
+
class Anthropic < BaseClient
|
|
11
|
+
CLAUDE_CODE_VERSION = "2.1.2"
|
|
12
|
+
|
|
13
|
+
def initialize(model_key: "claude-3-7-sonnet-20250219", api_key: ENV["ANTHROPIC_API_KEY"])
|
|
14
|
+
@base_endpoint = "https://api.anthropic.com/v1"
|
|
15
|
+
super(model_key: model_key, api_key: api_key)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def chat(messages, **kwargs)
|
|
19
|
+
post("messages", build_body(messages, **kwargs))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def stream(messages, **kwargs, &block)
|
|
23
|
+
post_stream("messages", build_body(messages, **kwargs), &block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_oauth_access_token(access_token:, refresh_token:, expires_at:, &block)
|
|
27
|
+
token_manager = LlmGateway::Clients::ClaudeCode::TokenManager.new(
|
|
28
|
+
access_token: access_token,
|
|
29
|
+
refresh_token: refresh_token,
|
|
30
|
+
expires_at: expires_at
|
|
31
|
+
)
|
|
32
|
+
token_manager.on_token_refresh = block if block_given?
|
|
33
|
+
token_manager.ensure_valid_token
|
|
34
|
+
token_manager.access_token
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def download_file(file_id)
|
|
38
|
+
get("files/#{file_id}/content")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def upload_file(filename, content, mime_type = "application/octet-stream")
|
|
42
|
+
post_file("files", content, filename, mime_type: mime_type)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_body(messages, tools: nil, system: [], cache_retention: nil, **options)
|
|
48
|
+
cache_control = anthropic_cache_control_for(cache_retention)
|
|
49
|
+
|
|
50
|
+
body = {
|
|
51
|
+
model: model_key,
|
|
52
|
+
messages: messages
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
tools = apply_tools_cache_control(tools, cache_retention)
|
|
56
|
+
body.merge!(tools: tools) if LlmGateway::Utils.present?(tools)
|
|
57
|
+
|
|
58
|
+
system = prepend_claude_code_identity(system) if claude_code_oauth_api_key?
|
|
59
|
+
system = apply_system_cache_control(system, cache_retention)
|
|
60
|
+
|
|
61
|
+
body.merge!(system: system) if LlmGateway::Utils.present?(system)
|
|
62
|
+
body.merge!(cache_control: cache_control) unless cache_control.nil?
|
|
63
|
+
body.merge!(options)
|
|
64
|
+
body
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply_system_cache_control(system, cache_retention)
|
|
68
|
+
return system if system.nil? || system.empty? || !system.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
cache_control = anthropic_cache_control_for(cache_retention)
|
|
71
|
+
return system if cache_control.nil?
|
|
72
|
+
|
|
73
|
+
last_index = system.length - 1
|
|
74
|
+
system.each_with_index.map do |block, index|
|
|
75
|
+
block = block.dup
|
|
76
|
+
if index == last_index
|
|
77
|
+
block[:cache_control] = cache_control
|
|
78
|
+
else
|
|
79
|
+
block.delete(:cache_control)
|
|
80
|
+
end
|
|
81
|
+
block
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def apply_tools_cache_control(tools, cache_retention)
|
|
86
|
+
return tools if tools.nil? || tools.empty? || !tools.is_a?(Array)
|
|
87
|
+
|
|
88
|
+
cache_control = anthropic_cache_control_for(cache_retention)
|
|
89
|
+
return tools if cache_control.nil?
|
|
90
|
+
|
|
91
|
+
last_index = tools.length - 1
|
|
92
|
+
tools.each_with_index.map do |tool, index|
|
|
93
|
+
tool = tool.dup
|
|
94
|
+
if index == last_index
|
|
95
|
+
tool[:cache_control] = cache_control
|
|
96
|
+
else
|
|
97
|
+
tool.delete(:cache_control)
|
|
98
|
+
end
|
|
99
|
+
tool
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def anthropic_cache_control_for(cache_retention)
|
|
104
|
+
return nil if cache_retention.nil?
|
|
105
|
+
|
|
106
|
+
retention = cache_retention.to_s
|
|
107
|
+
return nil if retention == "none"
|
|
108
|
+
|
|
109
|
+
cache_control = { type: "ephemeral" }
|
|
110
|
+
cache_control = cache_control.merge(ttl: "1h") if retention == "long" && anthropic_official_api?
|
|
111
|
+
cache_control
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def anthropic_official_api?
|
|
115
|
+
URI(base_endpoint).host == "api.anthropic.com"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_headers
|
|
119
|
+
return claude_code_oauth_headers if claude_code_oauth_api_key?
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
"anthropic-version" => "2023-06-01",
|
|
123
|
+
"content-type" => "application/json",
|
|
124
|
+
"x-api-key" => api_key,
|
|
125
|
+
"anthropic-beta" => "code-execution-2025-05-22,files-api-2025-04-14"
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def claude_code_oauth_api_key?
|
|
130
|
+
api_key.to_s.start_with?("sk-ant-oat")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def claude_code_oauth_headers
|
|
134
|
+
{
|
|
135
|
+
"anthropic-version" => "2023-06-01",
|
|
136
|
+
"content-type" => "application/json",
|
|
137
|
+
"Authorization" => "Bearer #{api_key}",
|
|
138
|
+
"anthropic-dangerous-direct-browser-access" => "true",
|
|
139
|
+
"anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
|
|
140
|
+
"user-agent" => "claude-cli/#{CLAUDE_CODE_VERSION} (external, cli)",
|
|
141
|
+
"x-app" => "cli"
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def prepend_claude_code_identity(system)
|
|
146
|
+
identity = {
|
|
147
|
+
type: "text",
|
|
148
|
+
text: "You are Claude Code, Anthropic's official CLI for Claude."
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if system.nil? || system.empty?
|
|
152
|
+
[ identity ]
|
|
153
|
+
else
|
|
154
|
+
[ identity ] + system
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def handle_client_specific_errors(response, error)
|
|
159
|
+
if Errors.context_overflow_message?(error["message"])
|
|
160
|
+
raise Errors::PromptTooLong.new(error["message"], error["type"])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
raise Errors::APIStatusError.new(error["message"], error["type"])
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "base64"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
module LlmGateway
|
|
12
|
+
module Clients
|
|
13
|
+
module ClaudeCode
|
|
14
|
+
class OAuthFlow
|
|
15
|
+
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
16
|
+
TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"
|
|
17
|
+
AUTH_URL = "https://claude.ai/oauth/authorize"
|
|
18
|
+
REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
|
19
|
+
DEFAULT_SCOPES = "org:create_api_key user:profile user:inference"
|
|
20
|
+
|
|
21
|
+
attr_reader :client_id, :redirect_uri, :scopes
|
|
22
|
+
|
|
23
|
+
def initialize(
|
|
24
|
+
client_id: CLIENT_ID,
|
|
25
|
+
redirect_uri: REDIRECT_URI,
|
|
26
|
+
scopes: DEFAULT_SCOPES
|
|
27
|
+
)
|
|
28
|
+
@client_id = client_id
|
|
29
|
+
@redirect_uri = redirect_uri
|
|
30
|
+
@scopes = scopes
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Step 1: Generate the authorization URL for the user to visit.
|
|
34
|
+
# Returns a hash with everything needed to complete the flow later.
|
|
35
|
+
def start(state: SecureRandom.hex(16))
|
|
36
|
+
code_verifier, code_challenge = generate_pkce
|
|
37
|
+
|
|
38
|
+
auth_url = build_authorization_url(code_challenge, state)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
authorization_url: auth_url,
|
|
42
|
+
code_verifier: code_verifier,
|
|
43
|
+
state: state
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Step 2: Exchange the authorization code for tokens.
|
|
48
|
+
# Accepts one of:
|
|
49
|
+
# - "code#state" (legacy format)
|
|
50
|
+
# - a raw authorization code, with state passed separately
|
|
51
|
+
# - a full callback URL containing ?code=...&state=...
|
|
52
|
+
# Returns { access_token:, refresh_token:, expires_at: }
|
|
53
|
+
def exchange_code(auth_code_or_callback, code_verifier, state: nil)
|
|
54
|
+
code, resolved_state = extract_code_and_state(auth_code_or_callback, state)
|
|
55
|
+
|
|
56
|
+
uri = URI(TOKEN_URL)
|
|
57
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
58
|
+
http.use_ssl = true
|
|
59
|
+
http.read_timeout = 30
|
|
60
|
+
http.open_timeout = 10
|
|
61
|
+
|
|
62
|
+
request = Net::HTTP::Post.new(uri)
|
|
63
|
+
request["Content-Type"] = "application/json"
|
|
64
|
+
|
|
65
|
+
request.body = {
|
|
66
|
+
grant_type: "authorization_code",
|
|
67
|
+
client_id: @client_id,
|
|
68
|
+
code: code,
|
|
69
|
+
state: resolved_state || "",
|
|
70
|
+
redirect_uri: @redirect_uri,
|
|
71
|
+
code_verifier: code_verifier
|
|
72
|
+
}.to_json
|
|
73
|
+
|
|
74
|
+
response = http.request(request)
|
|
75
|
+
|
|
76
|
+
if response.code.to_i == 200
|
|
77
|
+
data = JSON.parse(response.body)
|
|
78
|
+
|
|
79
|
+
expires_at = if data["expires_in"]
|
|
80
|
+
Time.now + data["expires_in"].to_i
|
|
81
|
+
elsif data["expires_at"]
|
|
82
|
+
Time.parse(data["expires_at"])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
access_token: data["access_token"],
|
|
87
|
+
refresh_token: data["refresh_token"],
|
|
88
|
+
expires_at: expires_at
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
error_body = begin
|
|
92
|
+
JSON.parse(response.body)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
96
|
+
raise Errors::AuthenticationError.new(
|
|
97
|
+
"OAuth token exchange failed: #{error_body["error_description"] || error_body["error"] || response.body}",
|
|
98
|
+
error_body["error"]
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_callback(callback_url)
|
|
104
|
+
uri = URI(callback_url)
|
|
105
|
+
code = uri.query && URI.decode_www_form(uri.query).to_h["code"]
|
|
106
|
+
state = uri.query && URI.decode_www_form(uri.query).to_h["state"]
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Callback URL is missing code parameter" if code.nil? || code.empty?
|
|
109
|
+
|
|
110
|
+
{ code: code, state: state }
|
|
111
|
+
rescue URI::InvalidURIError => e
|
|
112
|
+
raise ArgumentError, "Invalid callback URL: #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def extract_code_and_state(auth_code_or_callback, state)
|
|
118
|
+
value = auth_code_or_callback.to_s.strip
|
|
119
|
+
raise ArgumentError, "Authorization code is required" if value.empty?
|
|
120
|
+
|
|
121
|
+
if looks_like_url?(value)
|
|
122
|
+
callback = parse_callback(value)
|
|
123
|
+
[ callback[:code], callback[:state] || state ]
|
|
124
|
+
elsif value.include?("#")
|
|
125
|
+
code, parsed_state = value.split("#", 2)
|
|
126
|
+
[ code, parsed_state || state ]
|
|
127
|
+
else
|
|
128
|
+
[ value, state ]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def looks_like_url?(value)
|
|
133
|
+
value.start_with?("http://", "https://")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def generate_pkce
|
|
137
|
+
code_verifier = [ SecureRandom.random_bytes(32) ].pack("m0").tr("+/", "-_").tr("=", "")
|
|
138
|
+
|
|
139
|
+
digest = Digest::SHA256.digest(code_verifier)
|
|
140
|
+
code_challenge = [ digest ].pack("m0").tr("+/", "-_").tr("=", "")
|
|
141
|
+
|
|
142
|
+
[ code_verifier, code_challenge ]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_authorization_url(code_challenge, state)
|
|
146
|
+
params = {
|
|
147
|
+
code: "true",
|
|
148
|
+
client_id: @client_id,
|
|
149
|
+
response_type: "code",
|
|
150
|
+
redirect_uri: @redirect_uri,
|
|
151
|
+
scope: @scopes,
|
|
152
|
+
code_challenge: code_challenge,
|
|
153
|
+
code_challenge_method: "S256",
|
|
154
|
+
state: state
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
"#{AUTH_URL}?#{URI.encode_www_form(params)}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module LlmGateway
|
|
8
|
+
module Clients
|
|
9
|
+
module ClaudeCode
|
|
10
|
+
class TokenManager
|
|
11
|
+
TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"
|
|
12
|
+
CLIENT_ID = OAuthFlow::CLIENT_ID
|
|
13
|
+
|
|
14
|
+
attr_reader :refresh_token, :expires_at, :client_id, :client_secret, :access_token
|
|
15
|
+
attr_accessor :on_token_refresh
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
access_token: nil,
|
|
19
|
+
refresh_token:,
|
|
20
|
+
expires_at: nil,
|
|
21
|
+
client_id: CLIENT_ID,
|
|
22
|
+
client_secret: nil
|
|
23
|
+
)
|
|
24
|
+
@access_token = access_token
|
|
25
|
+
@refresh_token = refresh_token
|
|
26
|
+
@expires_at = parse_expires_at(expires_at)
|
|
27
|
+
@client_id = client_id
|
|
28
|
+
@client_secret = client_secret
|
|
29
|
+
@on_token_refresh = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def token_expired?
|
|
33
|
+
return true if @expires_at.nil?
|
|
34
|
+
Time.now >= @expires_at
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ensure_valid_token
|
|
38
|
+
refresh_access_token if token_expired?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def refresh_access_token
|
|
42
|
+
raise ArgumentError, "Cannot refresh token: refresh_token not provided" unless @refresh_token
|
|
43
|
+
raise ArgumentError, "Cannot refresh token: client_id not provided" unless @client_id
|
|
44
|
+
|
|
45
|
+
uri = URI(TOKEN_URL)
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = true
|
|
48
|
+
http.read_timeout = 30
|
|
49
|
+
http.open_timeout = 10
|
|
50
|
+
|
|
51
|
+
request = Net::HTTP::Post.new(uri)
|
|
52
|
+
request["Content-Type"] = "application/json"
|
|
53
|
+
|
|
54
|
+
request_body = {
|
|
55
|
+
grant_type: "refresh_token",
|
|
56
|
+
client_id: @client_id,
|
|
57
|
+
refresh_token: @refresh_token
|
|
58
|
+
}
|
|
59
|
+
request_body[:client_secret] = @client_secret if @client_secret
|
|
60
|
+
|
|
61
|
+
request.body = request_body.to_json
|
|
62
|
+
|
|
63
|
+
response = http.request(request)
|
|
64
|
+
|
|
65
|
+
if response.code.to_i == 200
|
|
66
|
+
data = JSON.parse(response.body)
|
|
67
|
+
@access_token = data["access_token"]
|
|
68
|
+
|
|
69
|
+
if data["refresh_token"]
|
|
70
|
+
@refresh_token = data["refresh_token"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if data["expires_in"]
|
|
74
|
+
@expires_at = Time.now + data["expires_in"].to_i
|
|
75
|
+
elsif data["expires_at"]
|
|
76
|
+
@expires_at = Time.parse(data["expires_at"])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@on_token_refresh&.call(@access_token, @refresh_token, @expires_at)
|
|
80
|
+
|
|
81
|
+
@access_token
|
|
82
|
+
else
|
|
83
|
+
error_body = begin
|
|
84
|
+
JSON.parse(response.body)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
{}
|
|
87
|
+
end
|
|
88
|
+
raise Errors::AuthenticationError.new(
|
|
89
|
+
"Failed to refresh token: #{error_body['error'] || response.body}",
|
|
90
|
+
error_body["error_code"]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def parse_expires_at(expires)
|
|
98
|
+
case expires
|
|
99
|
+
when Time
|
|
100
|
+
expires
|
|
101
|
+
when String
|
|
102
|
+
Time.parse(expires)
|
|
103
|
+
when Integer
|
|
104
|
+
Time.at(expires)
|
|
105
|
+
else
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|