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
@@ -2,107 +2,41 @@
2
2
 
3
3
  module LlmGateway
4
4
  class Client
5
- def self.provider_configs
6
- @provider_configs ||= {
7
- anthropic: {
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
- def self.responses(model, message, response_format: "text", tools: nil, system: nil, api_key: nil)
63
- provider = provider_from_model(model)
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
- config = get_provider_config(provider)
87
- client_options = { model_key: model }
88
- client_options[:api_key] = api_key if api_key
89
- config[:client].new(**client_options)
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
- client = build_client(provider, api_key: api_key)
95
- result = client.upload_file(*kwargs.values)
96
- config = get_provider_config(provider)
97
- config[:file_output_mapper].map(result)
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
- client = build_client(provider, api_key: api_key)
103
- result = client.download_file(*kwargs.values)
104
- config = get_provider_config(provider)
105
- config[:file_output_mapper].map(result)
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::Adapters::Claude::Client
54
+ when LlmGateway::Clients::Anthropic
132
55
  "anthropic"
133
- when LlmGateway::Adapters::OpenAi::Client
56
+ when LlmGateway::Clients::OpenAI
134
57
  "openai"
135
- when LlmGateway::Adapters::Groq::Client
58
+ when LlmGateway::Clients::Groq
136
59
  "groq"
137
60
  else
138
- raise LlmGateway::Errors::UnsupportedProvider, client.class.name
61
+ client.class.name.downcase
139
62
  end
140
63
  end
141
64
 
142
- def self.get_provider_config_by_client(client)
143
- provider_id = provider_id_from_client(client)
144
- get_provider_config(provider_id)
145
- end
65
+ # --- private helpers ---
146
66
 
147
- def self.normalize_system(system)
148
- if system.nil?
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
- def self.normalize_messages(message)
160
- if message.is_a?(String)
161
- [ { 'role': "user", 'content': message } ]
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
- message
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
- def self.normalize_response_format(response_format)
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