llm_gateway 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.pi/skills/live-provider-testing/SKILL.md +183 -0
  3. data/.pi/skills/options-development/SKILL.md +131 -0
  4. data/CHANGELOG.md +43 -0
  5. data/README.md +559 -185
  6. data/Rakefile +2 -2
  7. data/docs/migration-guide.md +135 -0
  8. data/lib/llm_gateway/adapters/adapter.rb +140 -0
  9. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
  10. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
  11. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  12. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
  13. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
  14. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
  15. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
  16. data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
  17. data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
  18. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  19. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  20. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
  21. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
  26. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
  27. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  28. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
  31. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
  32. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
  33. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  34. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  35. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
  36. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  37. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  38. data/lib/llm_gateway/adapters/structs.rb +145 -0
  39. data/lib/llm_gateway/base_client.rb +62 -1
  40. data/lib/llm_gateway/client.rb +18 -158
  41. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  42. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  43. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  44. data/lib/llm_gateway/clients/groq.rb +66 -0
  45. data/lib/llm_gateway/clients/openai.rb +208 -0
  46. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  47. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  48. data/lib/llm_gateway/errors.rb +21 -0
  49. data/lib/llm_gateway/prompt.rb +12 -1
  50. data/lib/llm_gateway/provider_registry.rb +37 -0
  51. data/lib/llm_gateway/version.rb +1 -1
  52. data/lib/llm_gateway.rb +162 -17
  53. data/scripts/create_anthropic_credentials.rb +106 -0
  54. data/scripts/create_openai_codex_credentials.rb +116 -0
  55. metadata +60 -27
  56. data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
  57. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  58. data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
  59. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
  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/output_mapper.rb +0 -10
  63. data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
  64. data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
  65. data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
  66. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  67. data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
  68. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  69. data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
  70. data/sample/claude_code_clone/agent.rb +0 -65
  71. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  72. data/sample/claude_code_clone/prompt.rb +0 -79
  73. data/sample/claude_code_clone/run.rb +0 -47
  74. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  75. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  76. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  77. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  78. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../adapter"
4
+ require_relative "../openai/acts_like_responses"
5
+ require_relative "option_mapper"
6
+ require_relative "../openai/responses/stream_mapper"
7
+ require_relative "../openai/file_output_mapper"
8
+ require_relative "input_mapper"
9
+ require_relative "../input_message_sanitizer"
10
+
11
+ module LlmGateway
12
+ module Adapters
13
+ module OpenAICodex
14
+ class ResponsesAdapter < Adapter
15
+ include ActsLikeOpenAIResponses
16
+
17
+ private
18
+
19
+ def input_mapper
20
+ OpenAICodex::InputMapper
21
+ end
22
+
23
+ def option_mapper
24
+ OptionMapper
25
+ end
26
+
27
+ def perform_stream(messages, tools:, system:, **options, &block)
28
+ client.stream_codex(messages, tools: tools, system: system, **options, &block)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module OptionMapper
6
+ module_function
7
+
8
+ def map(options)
9
+ options
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "normalized_stream_accumulator"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ class StreamMapper
8
+ def result
9
+ accumulator.result
10
+ end
11
+
12
+ private
13
+
14
+ def accumulator
15
+ @accumulator ||= LlmGateway::Adapters::NormalizedStreamAccumulator.new
16
+ end
17
+
18
+ def push_patches(patches, &block)
19
+ patches.each do |patch|
20
+ accumulator.push(patch, &block)
21
+ end
22
+
23
+ nil
24
+ end
25
+
26
+ def raise_stream_error!(data, overload_codes: [])
27
+ error = stream_error_payload(data)
28
+ message = error[:message] || error["message"] || "Stream error"
29
+ code = error[:code] || error["code"] || error[:type] || error["type"]
30
+
31
+ if LlmGateway::Errors.context_overflow_message?(message)
32
+ raise LlmGateway::Errors::PromptTooLong.new(message, code)
33
+ end
34
+
35
+ if Array(overload_codes).any? { |overload_code| overload_code.to_s == code.to_s }
36
+ raise LlmGateway::Errors::OverloadError.new(message, code)
37
+ end
38
+
39
+ raise LlmGateway::Errors::APIStatusError.new(message, code)
40
+ end
41
+
42
+ def stream_error_payload(data)
43
+ data ||= {}
44
+ error = data[:error] || data["error"]
45
+
46
+ error.is_a?(Hash) ? error : data
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,145 @@
1
+ require "dry-struct"
2
+ require "dry-types"
3
+
4
+ module Types
5
+ include Dry.Types()
6
+ end
7
+
8
+ class BaseStruct < Dry::Struct
9
+ transform_keys(&:to_sym)
10
+ end
11
+
12
+ class AssistantStreamEvent < BaseStruct
13
+ EventType = Types::Coercible::Symbol.enum(:text_start, :text_delta, :text_end, :tool_start, :tool_delta, :tool_end, :reasoning_start, :reasoning_delta, :reasoning_end)
14
+
15
+ attribute :type, EventType
16
+ attribute :delta, Types::Coercible::String.default { "" }
17
+ attribute :content_index, Types::Integer
18
+ end
19
+
20
+
21
+ class AssistantToolStartEvent < AssistantStreamEvent
22
+ attribute :id, Types::String
23
+ attribute :name, Types::String
24
+ attribute :content_index, Types::Integer
25
+ end
26
+
27
+
28
+ class AssistantStreamReasoningEvent < AssistantStreamEvent
29
+ attribute :signature, Types::Coercible::String.default { "" }
30
+ attribute :content_index, Types::Integer
31
+ end
32
+
33
+ class AssistantStreamMessageEvent < BaseStruct
34
+ EventType = Types::Coercible::Symbol.enum(:message_start, :message_delta, :message_end)
35
+
36
+ attribute :type, EventType
37
+ attribute :delta, Types::Coercible::Hash.default { {} }
38
+ attribute :usage_increment, Types::Coercible::Hash.default { {} }
39
+ end
40
+
41
+ class TextContent < BaseStruct
42
+ attribute :type, Types::String.enum("text")
43
+ attribute :text, Types::String
44
+
45
+ def to_h
46
+ {
47
+ type: type,
48
+ text: text
49
+ }
50
+ end
51
+ end
52
+
53
+ class ReasoningContent < BaseStruct
54
+ attribute :type, Types::String.enum("reasoning")
55
+ attribute :reasoning, Types::String
56
+ attribute? :signature, Types::String.optional
57
+
58
+ def to_h
59
+ result = {
60
+ type: type,
61
+ reasoning: reasoning
62
+ }
63
+ result[:signature] = signature unless signature.nil?
64
+ result
65
+ end
66
+ end
67
+
68
+ class ToolCall < BaseStruct
69
+ attribute :id, Types::String
70
+ attribute :type, Types::String.enum("tool_use")
71
+ attribute :name, Types::String
72
+ attribute :input, Types::Hash
73
+
74
+ def to_h
75
+ {
76
+ id: id,
77
+ type: type,
78
+ name: name,
79
+ input: input
80
+ }
81
+ end
82
+ end
83
+
84
+ class ToolResult < BaseStruct
85
+ attribute :type, Types::String.enum("tool_result")
86
+ attribute :tool_use_id, Types::String
87
+ attribute :content, Types::String
88
+ end
89
+
90
+ class AssistantMessage < BaseStruct
91
+ ContentBlock =
92
+ Types.Instance(TextContent) |
93
+ Types.Instance(ReasoningContent) |
94
+ Types.Instance(ToolCall)
95
+
96
+ attribute :id, Types::String
97
+ attribute :model, Types::String
98
+ attribute :usage, Types::Hash
99
+ attribute :role, Types::String.enum("assistant")
100
+ attribute :stop_reason, Types::String.enum("stop", "length", "tool_use", "toolUse", "error", "aborted")
101
+ attribute :provider, Types::String
102
+ attribute :api, Types::String
103
+ attribute? :error_message, Types::String.optional
104
+ attribute :content, Types::Array.of(ContentBlock)
105
+
106
+ def self.new(attributes)
107
+ attrs = attributes.to_h.transform_keys(&:to_sym)
108
+ attrs[:content] = Array(attrs[:content]).map { |block| build_content_block(block) }
109
+ super(attrs)
110
+ end
111
+
112
+ def to_h
113
+ result = {
114
+ id: id,
115
+ model: model,
116
+ usage: usage,
117
+ role: role,
118
+ stop_reason: stop_reason,
119
+ provider: provider,
120
+ api: api,
121
+ content: content.map(&:to_h)
122
+ }
123
+ result[:error_message] = error_message unless error_message.nil?
124
+ result
125
+ end
126
+
127
+ def self.build_content_block(block)
128
+ return block if block.is_a?(TextContent) || block.is_a?(ReasoningContent) || block.is_a?(ToolCall)
129
+
130
+ case block[:type] || block["type"]
131
+ when "text"
132
+ TextContent.new(block)
133
+ when "reasoning"
134
+ ReasoningContent.new(block)
135
+ when "thinking"
136
+ ReasoningContent.new(type: "reasoning", reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"], signature: block[:signature] || block["signature"])
137
+ when "tool_use"
138
+ ToolCall.new(block)
139
+ else
140
+ raise ArgumentError, "Unsupported content block type: #{block[:type] || block['type']}"
141
+ end
142
+ end
143
+
144
+ private_class_method :build_content_block
145
+ end
@@ -60,8 +60,70 @@ module LlmGateway
60
60
  process_response(response)
61
61
  end
62
62
 
63
+ def post_stream(url_part, body = nil, extra_headers = {}, &block)
64
+ endpoint = "#{base_endpoint}/#{url_part.sub(%r{^/}, "")}"
65
+ uri = URI(endpoint)
66
+ http = Net::HTTP.new(uri.host, uri.port)
67
+ http.use_ssl = true
68
+ http.read_timeout = 480
69
+ http.open_timeout = 10
70
+ body.merge!(stream: true)
71
+ request = Net::HTTP::Post.new(uri)
72
+ headers = build_headers.merge(extra_headers)
73
+ headers.each { |key, value| request[key] = value }
74
+ request.body = body.to_json if body
75
+
76
+ http.request(request) do |response|
77
+ unless response.code.to_i == 200
78
+ # Collect full body for error handling
79
+ full_body = +""
80
+ response.read_body { |chunk| full_body << chunk }
81
+ # Create a response-like object with the body for handle_error
82
+ response.instance_variable_set(:@body, full_body)
83
+ response.instance_variable_set(:@read, true)
84
+ handle_error(response)
85
+ end
86
+
87
+ parse_sse_stream(response, &block)
88
+ end
89
+ end
90
+
63
91
  protected
64
92
 
93
+ def parse_sse_stream(response)
94
+ buffer = +""
95
+ response.read_body do |chunk|
96
+ buffer << chunk
97
+ while (idx = buffer.index("\n\n"))
98
+ raw_event = buffer.slice!(0, idx + 2)
99
+ event_type = nil
100
+ data_lines = []
101
+
102
+ raw_event.each_line do |line|
103
+ line = line.chomp
104
+ if line.start_with?("event:")
105
+ event_type = line.sub(/^event:\s*/, "")
106
+ elsif line.start_with?("data:")
107
+ data_lines << line.sub(/^data:\s*/, "")
108
+ end
109
+ end
110
+
111
+ next if data_lines.empty?
112
+
113
+ data_str = data_lines.join("\n")
114
+ next if data_str == "[DONE]"
115
+
116
+ data = begin
117
+ LlmGateway::Utils.deep_symbolize_keys(JSON.parse(data_str))
118
+ rescue JSON::ParserError
119
+ { raw: data_str }
120
+ end
121
+
122
+ yield({ event: event_type, data: data })
123
+ end
124
+ end
125
+ end
126
+
65
127
  def make_request(endpoint, method, params = nil, extra_headers = {})
66
128
  uri = URI(endpoint)
67
129
  http = Net::HTTP.new(uri.host, uri.port)
@@ -73,7 +135,6 @@ module LlmGateway
73
135
  headers = build_headers.merge(extra_headers)
74
136
  headers.each { |key, value| request[key] = value }
75
137
  request.body = params.to_json if params
76
-
77
138
  http.request(request)
78
139
  end
79
140
 
@@ -1,175 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+
3
4
  module LlmGateway
4
5
  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)
59
- end
60
-
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)
83
- end
84
-
85
- 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)
90
- end
91
-
92
- def self.upload_file(provider, **kwargs)
93
- 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)
98
- end
99
-
100
- def self.download_file(provider, **kwargs)
101
- 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)
106
- end
107
-
108
- def self.provider_from_model(model)
109
- return "anthropic" if model.start_with?("claude")
110
- return "groq" if model.start_with?("llama")
111
- return "openai" if model.start_with?("gpt") ||
112
- model.start_with?("o4-") ||
113
- model.start_with?("openai")
114
-
115
- raise LlmGateway::Errors::UnsupportedModel, model
116
- end
117
-
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
6
  def self.provider_id_from_client(client)
130
7
  case client
131
- when LlmGateway::Adapters::Claude::Client
8
+ when LlmGateway::Clients::Anthropic
132
9
  "anthropic"
133
- when LlmGateway::Adapters::OpenAi::Client
10
+ when LlmGateway::Clients::OpenAI
134
11
  "openai"
135
- when LlmGateway::Adapters::Groq::Client
12
+ when LlmGateway::Clients::Groq
136
13
  "groq"
137
- else
138
- raise LlmGateway::Errors::UnsupportedProvider, client.class.name
139
- end
140
- end
141
-
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
146
-
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
14
  end
157
15
  end
158
16
 
159
- def self.normalize_messages(message)
160
- if message.is_a?(String)
161
- [ { 'role': "user", 'content': message } ]
162
- else
163
- message
164
- end
17
+ def self.upload_file(provider, **kwargs)
18
+ api_key = kwargs.delete(:api_key)
19
+ adapter = LlmGateway.build_provider(
20
+ provider: provider,
21
+ api_key: api_key
22
+ )
23
+ adapter.upload_file(**kwargs)
165
24
  end
166
25
 
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
26
+ def self.download_file(provider, **kwargs)
27
+ api_key = kwargs.delete(:api_key)
28
+ adapter = LlmGateway.build_provider(
29
+ provider: provider,
30
+ api_key: api_key
31
+ )
32
+ adapter.download_file(**kwargs)
173
33
  end
174
34
  end
175
35
  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