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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +565 -129
  4. data/Rakefile +8 -3
  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/anthropic/bidirectional_message_mapper.rb +111 -0
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
  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/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
  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/openai/chat_completions/output_mapper.rb +40 -0
  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/openai/file_output_mapper.rb +25 -0
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
  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/openai/responses/output_mapper.rb +47 -0
  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/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
  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 +97 -1
  41. data/lib/llm_gateway/client.rb +66 -54
  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 +23 -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 +169 -10
  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 -21
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -56
  60. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
  65. data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
  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
@@ -0,0 +1,91 @@
1
+ require "json"
2
+
3
+ class StreamAccumulator
4
+ attr_accessor :blocks, :message_hash, :usage_hash
5
+
6
+ def initialize
7
+ @message_hash = {}
8
+ @usage_hash = {
9
+ input_tokens: 0,
10
+ cache_creation_input_tokens: 0,
11
+ cache_read_input_tokens: 0,
12
+ output_tokens: 0,
13
+ reasoning_tokens: 0
14
+ }
15
+ @blocks = []
16
+ end
17
+
18
+ def result
19
+ message_hash.merge(
20
+ usage: usage_hash,
21
+ content: serialized_blocks
22
+ )
23
+ end
24
+
25
+ def push(event)
26
+ return unless event
27
+
28
+ case event.type
29
+ when :text_start
30
+ blocks[event.content_index] = {
31
+ type: "text",
32
+ text: ""
33
+ }
34
+ blocks[event.content_index][:text] += event.delta
35
+ when :text_delta, :text_end
36
+ blocks[event.content_index][:text] += event.delta
37
+ when :tool_start
38
+ blocks[event.content_index] = {
39
+ type: "tool_use",
40
+ id: event.id,
41
+ name: event.name,
42
+ input: ""
43
+ }
44
+ when :tool_delta, :tool_end
45
+ blocks[event.content_index][:input] += event.delta
46
+ when :message_start
47
+ message_hash.merge!(event.delta)
48
+ usage_hash.each_key do |key|
49
+ usage_hash[key] += event.usage_increment.fetch(key, 0)
50
+ end
51
+ when :reasoning_start
52
+ blocks[event.content_index] = {
53
+ type: "reasoning",
54
+ reasoning: "",
55
+ signature: ""
56
+ }
57
+ blocks[event.content_index][:reasoning] += event.delta
58
+ blocks[event.content_index][:signature] += event.respond_to?(:signature) ? event.signature : ""
59
+ when :reasoning_delta
60
+ blocks[event.content_index][:reasoning] += event.delta
61
+ blocks[event.content_index][:signature] += event.signature
62
+ when :reasoning_end
63
+ blocks[event.content_index][:reasoning] += event.delta
64
+ blocks[event.content_index][:signature] += event.respond_to?(:signature) ? event.signature : ""
65
+ when :message_delta
66
+ message_hash.merge!(event.delta)
67
+ usage_hash.each_key do |key|
68
+ usage_hash[key] += event.usage_increment.fetch(key, 0)
69
+ end
70
+ when :message_end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def serialized_blocks
77
+ blocks.map do |block|
78
+ next block unless block[:type] == "tool_use"
79
+
80
+ block.merge(input: LlmGateway::Utils.deep_symbolize_keys(parse_tool_input(block[:input])))
81
+ end
82
+ end
83
+
84
+ def parse_tool_input(input)
85
+ return {} if input.nil? || input.empty?
86
+
87
+ JSON.parse(input)
88
+ rescue JSON::ParserError
89
+ {}
90
+ end
91
+ 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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "stringio"
4
5
  require "json"
5
6
 
6
7
  module LlmGateway
@@ -19,14 +20,110 @@ module LlmGateway
19
20
  process_response(response)
20
21
  end
21
22
 
23
+ def post_file(url_part, file_contents, filename, purpose: nil, mime_type: "application/octet-stream")
24
+ endpoint = "#{base_endpoint}/#{url_part.sub(%r{^/}, "")}"
25
+ uri = URI.parse(endpoint)
26
+
27
+ file_io = StringIO.new(file_contents)
28
+
29
+ # Create request with full URI (important!)
30
+ request = Net::HTTP::Post.new(uri)
31
+
32
+ form_data = [
33
+ [
34
+ "file",
35
+ file_io,
36
+ { filename: filename, "Content-Type" => mime_type }
37
+ ]
38
+ ]
39
+
40
+ # Add purpose parameter if provided
41
+ form_data << [ "purpose", purpose ] if purpose
42
+
43
+ request.set_form(form_data, "multipart/form-data")
44
+
45
+ # Headers (excluding Content-Type because set_form already sets it)
46
+ multipart_headers = build_headers.reject { |k, _| k.downcase == "content-type" }
47
+ multipart_headers.each { |key, value| request[key] = value }
48
+
49
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
50
+ http.request(request)
51
+ end
52
+
53
+
54
+ process_response(response)
55
+ end
56
+
22
57
  def post(url_part, body = nil, extra_headers = {})
23
58
  endpoint = "#{base_endpoint}/#{url_part.sub(%r{^/}, "")}"
24
59
  response = make_request(endpoint, Net::HTTP::Post, body, extra_headers)
25
60
  process_response(response)
26
61
  end
27
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
+
28
91
  protected
29
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
+
30
127
  def make_request(endpoint, method, params = nil, extra_headers = {})
31
128
  uri = URI(endpoint)
32
129
  http = Net::HTTP.new(uri.host, uri.port)
@@ -38,7 +135,6 @@ module LlmGateway
38
135
  headers = build_headers.merge(extra_headers)
39
136
  headers.each { |key, value| request[key] = value }
40
137
  request.body = params.to_json if params
41
-
42
138
  http.request(request)
43
139
  end
44
140
 
@@ -2,78 +2,90 @@
2
2
 
3
3
  module LlmGateway
4
4
  class Client
5
- def self.chat(model, message, response_format: "text", tools: nil, system: nil, api_key: nil)
6
- client_klass = client_class(model)
7
- client_options = { model_key: model }
8
- client_options[:api_key] = api_key if api_key
9
- client = client_klass.new(**client_options)
10
-
11
- input_mapper = input_mapper_for_client(client)
12
- normalized_input = input_mapper.map({
13
- messages: normalize_messages(message),
14
- response_format: normalize_response_format(response_format),
15
- tools: tools,
16
- system: normalize_system(system)
17
- })
18
- result = client.chat(
19
- normalized_input[:messages],
20
- response_format: normalized_input[:response_format],
21
- tools: normalized_input[:tools],
22
- system: normalized_input[:system]
23
- )
24
- 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)
25
8
  end
26
9
 
27
- def self.client_class(model)
28
- return LlmGateway::Adapters::Claude::Client if model.start_with?("claude")
29
- return LlmGateway::Adapters::Groq::Client if model.start_with?("llama")
30
- return LlmGateway::Adapters::OpenAi::Client if model.start_with?("gpt") ||
31
- model.start_with?("o4-") ||
32
- model.start_with?("openai")
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)
13
+ end
33
14
 
34
- raise LlmGateway::Errors::UnsupportedModel, model
15
+ def self.build_client(provider, api_key:, model: "none")
16
+ adapter = LlmGateway.build_provider(
17
+ provider: provider,
18
+ api_key: api_key,
19
+ model_key: model
20
+ )
21
+ adapter.client
35
22
  end
36
23
 
37
- def self.input_mapper_for_client(client)
38
- return LlmGateway::Adapters::Claude::InputMapper if client.is_a?(LlmGateway::Adapters::Claude::Client)
39
- return LlmGateway::Adapters::OpenAi::InputMapper if client.is_a?(LlmGateway::Adapters::OpenAi::Client)
24
+ def self.upload_file(provider, **kwargs)
25
+ api_key = kwargs.delete(:api_key)
26
+ adapter = LlmGateway.build_provider(
27
+ provider: provider,
28
+ api_key: api_key
29
+ )
30
+ adapter.upload_file(**kwargs)
31
+ end
40
32
 
41
- LlmGateway::Adapters::Groq::InputMapper if client.is_a?(LlmGateway::Adapters::Groq::Client)
33
+ def self.download_file(provider, **kwargs)
34
+ api_key = kwargs.delete(:api_key)
35
+ adapter = LlmGateway.build_provider(
36
+ provider: provider,
37
+ api_key: api_key
38
+ )
39
+ adapter.download_file(**kwargs)
42
40
  end
43
41
 
44
- def self.result_mapper(client)
45
- return LlmGateway::Adapters::Claude::OutputMapper if client.is_a?(LlmGateway::Adapters::Claude::Client)
46
- return LlmGateway::Adapters::OpenAi::OutputMapper if client.is_a?(LlmGateway::Adapters::OpenAi::Client)
42
+ def self.provider_from_model(model)
43
+ return "anthropic" if model.start_with?("claude")
44
+ return "groq" if model.start_with?("llama")
45
+ return "openai" if model.start_with?("gpt") ||
46
+ model.start_with?("o4-") ||
47
+ model.start_with?("openai")
47
48
 
48
- LlmGateway::Adapters::Groq::OutputMapper if client.is_a?(LlmGateway::Adapters::Groq::Client)
49
+ raise LlmGateway::Errors::UnsupportedModel, model
49
50
  end
50
51
 
51
- def self.normalize_system(system)
52
- if system.nil?
53
- []
54
- elsif system.is_a?(String)
55
- [ { role: "system", content: system } ]
56
- elsif system.is_a?(Array)
57
- system
52
+ def self.provider_id_from_client(client)
53
+ case client
54
+ when LlmGateway::Clients::Anthropic
55
+ "anthropic"
56
+ when LlmGateway::Clients::OpenAI
57
+ "openai"
58
+ when LlmGateway::Clients::Groq
59
+ "groq"
58
60
  else
59
- raise ArgumentError, "System parameter must be a string or array, got #{system.class}"
61
+ client.class.name.downcase
60
62
  end
61
63
  end
62
64
 
63
- def self.normalize_messages(message)
64
- if message.is_a?(String)
65
- [ { 'role': "user", 'content': message } ]
66
- else
67
- message
68
- end
69
- end
65
+ # --- private helpers ---
66
+
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)
70
69
 
71
- def self.normalize_response_format(response_format)
72
- if response_format.is_a?(String)
73
- { type: response_format }
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)
74
77
  else
75
- response_format
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)
76
86
  end
77
87
  end
88
+
89
+ private_class_method :build_adapter_from_model
78
90
  end
79
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