Skip to content

Commit 177debc

Browse files
committed
feat: Add with_headers method to Chat for custom HTTP headers
Enables use of provider-specific features like beta headers while maintaining proper header precedence for security. Closes #85, #105
1 parent d826941 commit 177debc

File tree

9 files changed

+248
-9
lines changed

9 files changed

+248
-9
lines changed

lib/ruby_llm/active_record/acts_as.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ def with_params(...)
138138
self
139139
end
140140

141+
def with_headers(...)
142+
to_llm.with_headers(...)
143+
self
144+
end
145+
141146
def with_schema(...)
142147
to_llm.with_schema(...)
143148
self

lib/ruby_llm/chat.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ module RubyLLM
1111
class Chat
1212
include Enumerable
1313

14-
attr_reader :model, :messages, :tools, :params, :schema
14+
attr_reader :model, :messages, :tools, :params, :headers, :schema
1515

1616
def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
1717
if assume_model_exists && !provider
@@ -26,6 +26,7 @@ def initialize(model: nil, provider: nil, assume_model_exists: false, context: n
2626
@messages = []
2727
@tools = {}
2828
@params = {}
29+
@headers = {}
2930
@schema = nil
3031
@on = {
3132
new_message: nil,
@@ -87,6 +88,11 @@ def with_params(**params)
8788
self
8889
end
8990

91+
def with_headers(**headers)
92+
@headers = headers
93+
self
94+
end
95+
9096
def with_schema(schema, force: false)
9197
unless force || @model.structured_output?
9298
raise UnsupportedStructuredOutputError, "Model #{@model.id} doesn't support structured output"
@@ -135,6 +141,7 @@ def complete(&) # rubocop:disable Metrics/PerceivedComplexity
135141
temperature: @temperature,
136142
model: @model.id,
137143
params: @params,
144+
headers: @headers,
138145
schema: @schema,
139146
&wrap_streaming_block(&)
140147
)

lib/ruby_llm/provider.rb

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def configuration_requirements
4040
self.class.configuration_requirements
4141
end
4242

43-
def complete(messages, tools:, temperature:, model:, params: {}, schema: nil, &) # rubocop:disable Metrics/ParameterLists
43+
def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, &) # rubocop:disable Metrics/ParameterLists
4444
normalized_temperature = maybe_normalize_temperature(temperature, model)
4545

4646
payload = Utils.deep_merge(
@@ -56,9 +56,9 @@ def complete(messages, tools:, temperature:, model:, params: {}, schema: nil, &)
5656
)
5757

5858
if block_given?
59-
stream_response @connection, payload, &
59+
stream_response @connection, payload, headers, &
6060
else
61-
sync_response @connection, payload
61+
sync_response @connection, payload, headers
6262
end
6363
end
6464

@@ -208,8 +208,11 @@ def maybe_normalize_temperature(temperature, _model_id)
208208
temperature
209209
end
210210

211-
def sync_response(connection, payload)
212-
response = connection.post completion_url, payload
211+
def sync_response(connection, payload, additional_headers = {})
212+
response = connection.post completion_url, payload do |req|
213+
# Merge additional headers, with existing headers taking precedence
214+
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
215+
end
213216
parse_completion_response response
214217
end
215218
end

lib/ruby_llm/providers/bedrock/chat.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ class Bedrock
77
module Chat
88
module_function
99

10-
def sync_response(connection, payload)
10+
def sync_response(connection, payload, additional_headers = {})
1111
signature = sign_request("#{connection.connection.url_prefix}#{completion_url}", payload:)
1212
response = connection.post completion_url, payload do |req|
1313
req.headers.merge! build_headers(signature.headers, streaming: block_given?)
14+
# Merge additional headers, with existing headers taking precedence
15+
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
1416
end
1517
Anthropic::Chat.parse_completion_response response
1618
end

lib/ruby_llm/providers/bedrock/streaming/base.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ def stream_url
2929
"model/#{@model_id}/invoke-with-response-stream"
3030
end
3131

32-
def stream_response(connection, payload, &block)
32+
def stream_response(connection, payload, additional_headers = {}, &block)
3333
signature = sign_request("#{connection.connection.url_prefix}#{stream_url}", payload:)
3434
accumulator = StreamAccumulator.new
3535

3636
response = connection.post stream_url, payload do |req|
3737
req.headers.merge! build_headers(signature.headers, streaming: block_given?)
38+
# Merge additional headers, with existing headers taking precedence
39+
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
3840
req.options.on_data = handle_stream do |chunk|
3941
accumulator.add chunk
4042
block.call chunk

lib/ruby_llm/streaming.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ module RubyLLM
88
module Streaming
99
module_function
1010

11-
def stream_response(connection, payload, &block)
11+
def stream_response(connection, payload, additional_headers = {}, &block)
1212
accumulator = StreamAccumulator.new
1313

1414
response = connection.post stream_url, payload do |req|
15+
# Merge additional headers, with existing headers taking precedence
16+
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
1517
if req.options.respond_to?(:on_data)
1618
# Handle Faraday 2.x streaming with on_data method
1719
req.options.on_data = handle_stream do |chunk|

spec/fixtures/vcr_cassettes/RubyLLM_Chat/_with_headers/with_Anthropic_beta_headers/works_with_anthropic-beta_header_for_fine-grained_tool_streaming.yml

Lines changed: 83 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

spec/ruby_llm/active_record/acts_as_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,33 @@ def execute(expression:)
131131
end
132132
end
133133

134+
describe 'custom headers' do
135+
it 'supports with_headers for custom HTTP headers' do
136+
chat = Chat.create!(model_id: model)
137+
138+
result = chat.with_headers('X-Custom-Header' => 'test-value')
139+
expect(result).to eq(chat) # Should return self for chaining
140+
141+
# Verify the headers are passed through to the underlying chat
142+
llm_chat = chat.instance_variable_get(:@chat)
143+
expect(llm_chat.headers).to eq('X-Custom-Header' => 'test-value')
144+
end
145+
146+
it 'allows chaining with_headers with other methods' do
147+
chat = Chat.create!(model_id: model)
148+
149+
result = chat
150+
.with_temperature(0.5)
151+
.with_headers('X-Test' => 'value')
152+
.with_tool(Calculator)
153+
154+
expect(result).to eq(chat)
155+
156+
llm_chat = chat.instance_variable_get(:@chat)
157+
expect(llm_chat.headers).to eq('X-Test' => 'value')
158+
end
159+
end
160+
134161
describe 'error handling' do
135162
it 'destroys empty assistant messages on API failure' do
136163
chat = Chat.create!(model_id: model)

spec/ruby_llm/chat_headers_spec.rb

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
RSpec.describe RubyLLM::Chat do
6+
include_context 'with configured RubyLLM'
7+
describe '#with_headers' do
8+
it 'stores headers' do
9+
chat = RubyLLM.chat.with_headers('X-Custom-Header' => 'value')
10+
expect(chat.headers).to eq('X-Custom-Header' => 'value')
11+
end
12+
13+
it 'returns self for chaining' do
14+
chat = RubyLLM.chat
15+
expect(chat.with_headers('X-Test' => 'test')).to eq(chat)
16+
end
17+
18+
it 'passes headers to provider complete method' do
19+
chat = RubyLLM.chat
20+
provider = chat.instance_variable_get(:@provider)
21+
22+
allow(provider).to receive(:complete).and_return(
23+
RubyLLM::Message.new(role: :assistant, content: 'Test response')
24+
)
25+
26+
chat.with_headers('X-Custom' => 'header').ask('Test')
27+
28+
expect(provider).to have_received(:complete).with(
29+
anything,
30+
hash_including(headers: { 'X-Custom' => 'header' })
31+
)
32+
end
33+
34+
it 'allows chaining with other methods' do
35+
chat = RubyLLM.chat
36+
.with_temperature(0.5)
37+
.with_headers('X-Test' => 'value')
38+
.with_params(max_tokens: 100)
39+
40+
expect(chat.headers).to eq('X-Test' => 'value')
41+
expect(chat.params).to eq(max_tokens: 100)
42+
expect(chat.instance_variable_get(:@temperature)).to eq(0.5)
43+
end
44+
45+
context 'with Anthropic beta headers' do
46+
it 'works with anthropic-beta header for fine-grained tool streaming', :vcr do
47+
skip unless ENV['ANTHROPIC_API_KEY']
48+
49+
chat = RubyLLM.chat(model: 'claude-3-5-haiku-20241022', provider: 'anthropic')
50+
.with_headers('anthropic-beta' => 'fine-grained-tool-streaming-2025-05-14')
51+
52+
response = chat.ask('Say "beta headers work"')
53+
expect(response.content).to include('beta headers work')
54+
end
55+
end
56+
57+
context 'with header precedence' do
58+
it 'user headers do not override provider headers' do
59+
chat = RubyLLM.chat
60+
connection = chat.instance_variable_get(:@connection)
61+
provider = chat.instance_variable_get(:@provider)
62+
63+
# Mock provider headers
64+
allow(provider).to receive_messages(
65+
headers: {
66+
'X-Api-Key' => 'provider-key',
67+
'Content-Type' => 'application/json'
68+
},
69+
parse_completion_response: RubyLLM::Message.new(role: :assistant, content: 'Test')
70+
)
71+
72+
# Set user headers that try to override provider headers
73+
chat.with_headers(
74+
'X-Api-Key' => 'user-key',
75+
'X-Custom' => 'user-value'
76+
)
77+
78+
# Mock the connection.post to verify header merging
79+
allow(connection).to receive(:post) do |_url, _payload, &block|
80+
req = instance_double(Faraday::Request)
81+
initial_headers = {
82+
'X-Api-Key' => 'provider-key',
83+
'Content-Type' => 'application/json'
84+
}
85+
86+
allow(req).to receive(:headers).and_return(initial_headers)
87+
allow(req).to receive(:headers=) do |merged_headers|
88+
# Provider headers should take precedence
89+
expect(merged_headers['X-Api-Key']).to eq('provider-key')
90+
expect(merged_headers['Content-Type']).to eq('application/json')
91+
# User headers should be added
92+
expect(merged_headers['X-Custom']).to eq('user-value')
93+
end
94+
95+
block&.call(req)
96+
97+
instance_double(Faraday::Response,
98+
body: {
99+
'content' => [{ 'type' => 'text', 'text' => 'Test' }],
100+
'model' => 'test-model'
101+
})
102+
end
103+
104+
chat.ask('Test')
105+
end
106+
end
107+
end
108+
end

0 commit comments

Comments
 (0)