Skip to content

Commit 31038c4

Browse files
Add streaming support for Anthropic (#809)
* Add streaming support for Anthropic Fix text streaming Stream chunk instead of text * Return the input as a hash * Stream all chunks * Updated CHANGELOG * Update README --------- Co-authored-by: Andrei Bondarev <[email protected]>
1 parent 6364089 commit 31038c4

File tree

6 files changed

+201
-1
lines changed

6 files changed

+201
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## [Unreleased]
2+
- Added support for streaming with Anthropic
23
- Bump anthropic gem
34
- Default Langchain::LLM::Anthropic chat model is "claude-3-5-sonnet-20240620" now
45

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,8 @@ assistant.add_message(content: "Hello")
525525
assistant.run(auto_tool_execution: true)
526526
```
527527

528+
Note that streaming is not currently supported for all LLMs.
529+
528530
### Configuration
529531
* `llm`: The LLM instance to use (required)
530532
* `tools`: An array of tool instances (optional)

lib/langchain/llm/anthropic.rb

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def complete(
100100
# @option params [Integer] :top_k Only sample from the top K options for each subsequent token
101101
# @option params [Float] :top_p Use nucleus sampling.
102102
# @return [Langchain::LLM::AnthropicResponse] The chat completion
103-
def chat(params = {})
103+
def chat(params = {}, &block)
104104
set_extra_headers! if params[:tools]
105105

106106
parameters = chat_parameters.to_params(params)
@@ -109,8 +109,19 @@ def chat(params = {})
109109
raise ArgumentError.new("model argument is required") if parameters[:model].empty?
110110
raise ArgumentError.new("max_tokens argument is required") if parameters[:max_tokens].nil?
111111

112+
if block
113+
@response_chunks = []
114+
parameters[:stream] = proc do |chunk|
115+
@response_chunks << chunk
116+
yield chunk
117+
end
118+
end
119+
112120
response = client.messages(parameters: parameters)
113121

122+
response = response_from_chunks if block
123+
reset_response_chunks
124+
114125
Langchain::LLM::AnthropicResponse.new(response)
115126
end
116127

@@ -123,8 +134,53 @@ def with_api_error_handling
123134
response
124135
end
125136

137+
def response_from_chunks
138+
grouped_chunks = @response_chunks.group_by { |chunk| chunk["index"] }.except(nil)
139+
140+
usage = @response_chunks.find { |chunk| chunk["type"] == "message_delta" }&.dig("usage")
141+
stop_reason = @response_chunks.find { |chunk| chunk["type"] == "message_delta" }&.dig("delta", "stop_reason")
142+
143+
content = grouped_chunks.map do |_index, chunks|
144+
text = chunks.map { |chunk| chunk.dig("delta", "text") }.join
145+
if !text.nil? && !text.empty?
146+
{"type" => "text", "text" => text}
147+
else
148+
tool_calls_from_choice_chunks(chunks)
149+
end
150+
end.flatten
151+
152+
@response_chunks.first&.slice("id", "object", "created", "model")
153+
&.merge!(
154+
{
155+
"content" => content,
156+
"usage" => usage,
157+
"role" => "assistant",
158+
"stop_reason" => stop_reason
159+
}
160+
)
161+
end
162+
163+
def tool_calls_from_choice_chunks(chunks)
164+
return unless (first_block = chunks.find { |chunk| chunk.dig("content_block", "type") == "tool_use" })
165+
166+
chunks.group_by { |chunk| chunk["index"] }.map do |index, chunks|
167+
input = chunks.select { |chunk| chunk.dig("delta", "partial_json") }
168+
.map! { |chunk| chunk.dig("delta", "partial_json") }.join
169+
{
170+
"id" => first_block.dig("content_block", "id"),
171+
"type" => "tool_use",
172+
"name" => first_block.dig("content_block", "name"),
173+
"input" => JSON.parse(input).transform_keys(&:to_sym)
174+
}
175+
end.compact
176+
end
177+
126178
private
127179

180+
def reset_response_chunks
181+
@response_chunks = []
182+
end
183+
128184
def set_extra_headers!
129185
::Anthropic.configuration.extra_headers = {"anthropic-beta": "tools-2024-05-16"}
130186
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
[
2+
{
3+
"type": "message_start",
4+
"message": {
5+
"id": "msg_019s6T825xb66ZLwPWmvH875",
6+
"type": "message",
7+
"role": "assistant",
8+
"model": "claude-3-sonnet-20240229",
9+
"content": [],
10+
"stop_reason": null,
11+
"stop_sequence": null,
12+
"usage": {
13+
"input_tokens": 5,
14+
"output_tokens": 10
15+
}
16+
}
17+
},
18+
{
19+
"type": "content_block_start",
20+
"index": 0,
21+
"content_block": {
22+
"type": "text",
23+
"text": ""
24+
}
25+
},
26+
{
27+
"type": "ping"
28+
},
29+
{
30+
"type": "content_block_delta",
31+
"index": 0,
32+
"delta": {
33+
"type": "text_delta",
34+
"text": "Life is"
35+
}
36+
},
37+
{
38+
"type": "content_block_delta",
39+
"index": 0,
40+
"delta": {
41+
"type": "text_delta",
42+
"text": " pretty good"
43+
}
44+
},
45+
{
46+
"type": "content_block_stop",
47+
"index": 0
48+
},
49+
{
50+
"type": "message_delta",
51+
"delta": {
52+
"stop_reason": "max_tokens",
53+
"stop_sequence": null
54+
},
55+
"usage": {
56+
"output_tokens": 10
57+
}
58+
},
59+
{
60+
"type": "message_stop"
61+
}
62+
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[
2+
{"type":"message_start","message":{"id":"msg_014p7gG3wDgGV9EUtLvnow3U","type":"message","role":"assistant","model":"claude-3-haiku-20240307","stop_sequence":null,"usage":{"input_tokens":472,"output_tokens":2},"content":[],"stop_reason":null}},
3+
{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}},
4+
{"type": "ping"},
5+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Okay"}},
6+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":","}},
7+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" let"}},
8+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"'s"}},
9+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" check"}},
10+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" the"}},
11+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" weather"}},
12+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" for"}},
13+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" San"}},
14+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Francisco"}},
15+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":","}},
16+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" CA"}},
17+
{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":":"}},
18+
{"type":"content_block_stop","index":0},
19+
{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01T1x1fJ34qAmk2tNTrN7Up6","name":"get_weather","input":{}}},
20+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""}},
21+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"location\":"}},
22+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" \"San"}},
23+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" Francisc"}},
24+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"o,"}},
25+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":" CA\""}},
26+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":", "}},
27+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"unit\": \"fah"}},
28+
{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"renheit\"}"}},
29+
{"type":"content_block_stop","index":1},
30+
{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":89}},
31+
{"type":"message_stop"}
32+
]

spec/langchain/llm/anthropic_spec.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,52 @@
8181
).to eq("claude-3-sonnet-20240229")
8282
end
8383
end
84+
85+
context "with streaming" do
86+
let(:fixture) { File.read("spec/fixtures/llm/anthropic/chat_stream.json") }
87+
let(:response) { JSON.parse(fixture) }
88+
let(:stream_handler) { proc { _1 } }
89+
90+
before do
91+
allow(subject.client).to receive(:messages) do |parameters|
92+
response.each do |chunk|
93+
parameters[:parameters][:stream].call(chunk)
94+
end
95+
end.and_return("This response will be overritten.")
96+
end
97+
98+
it "handles streaming responses correctly" do
99+
rsp = subject.chat(messages: messages, &stream_handler)
100+
expect(rsp).to be_a(Langchain::LLM::AnthropicResponse)
101+
expect(rsp.completion_tokens).to eq(10)
102+
expect(rsp.total_tokens).to eq(10)
103+
expect(rsp.chat_completion).to eq("Life is pretty good")
104+
end
105+
end
106+
107+
context "with streaming tools" do
108+
let(:fixture) { File.read("spec/fixtures/llm/anthropic/chat_stream_with_tool_calls.json") }
109+
let(:response) { JSON.parse(fixture) }
110+
let(:stream_handler) { proc { _1 } }
111+
112+
before do
113+
allow(subject.client).to receive(:messages) do |parameters|
114+
response.each do |chunk|
115+
parameters[:parameters][:stream].call(chunk)
116+
end
117+
end.and_return("This response will be overritten.")
118+
end
119+
120+
it "handles streaming responses correctly" do
121+
rsp = subject.chat(messages: messages, &stream_handler)
122+
expect(rsp).to be_a(Langchain::LLM::AnthropicResponse)
123+
expect(rsp.completion_tokens).to eq(89)
124+
expect(rsp.total_tokens).to eq(89)
125+
expect(rsp.chat_completion).to eq("Okay, let's check the weather for San Francisco, CA:")
126+
127+
expect(rsp.tool_calls.first["name"]).to eq("get_weather")
128+
expect(rsp.tool_calls.first["input"]).to eq({location: "San Francisco, CA", unit: "fahrenheit"})
129+
end
130+
end
84131
end
85132
end

0 commit comments

Comments
 (0)