@@ -45,10 +45,61 @@ def test_creates_span_with_attributes
4545 assert_equal "openai" , span . attributes [ "gen_ai.provider.name" ]
4646 assert_equal "gpt-4o-mini" , span . attributes [ "gen_ai.request.model" ]
4747 assert_equal "chat" , span . attributes [ "gen_ai.operation.name" ]
48+ # Per GenAI semconv, `gen_ai.request.stream` is set only when streaming.
49+ assert_nil span . attributes [ "gen_ai.request.stream" ]
4850 assert_equal 10 , span . attributes [ "gen_ai.usage.input_tokens" ]
4951 assert_equal 5 , span . attributes [ "gen_ai.usage.output_tokens" ]
5052 end
5153
54+ def test_marks_streaming_chat_requests
55+ stub_request ( :post , "https://api.openai.com/v1/chat/completions" )
56+ . to_return (
57+ status : 200 ,
58+ headers : { "Content-Type" => "application/json" } ,
59+ body : {
60+ id : "chatcmpl-123" ,
61+ model : "gpt-4o-mini" ,
62+ choices : [ { index : 0 , message : { role : "assistant" , content : "Hi" } , finish_reason : "stop" } ] ,
63+ usage : { prompt_tokens : 1 , completion_tokens : 1 , total_tokens : 2 }
64+ } . to_json
65+ )
66+
67+ chat = RubyLLM . chat ( model : "gpt-4o-mini" )
68+ chat . ask ( "Hi" ) { |_chunk | }
69+
70+ span = EXPORTER . finished_spans . first
71+ assert_equal true , span . attributes [ "gen_ai.request.stream" ]
72+ end
73+
74+ def test_records_prompt_cache_tokens
75+ # RubyLLM's OpenAI provider maps `cached_tokens` ← `cache_read_tokens(usage)`
76+ # and `cache_creation_tokens` ← `cache_write_tokens(usage)`, both surfaced
77+ # on `Message#cached_tokens` / `Message#cache_creation_tokens`.
78+ stub_request ( :post , "https://api.openai.com/v1/chat/completions" )
79+ . to_return (
80+ status : 200 ,
81+ headers : { "Content-Type" => "application/json" } ,
82+ body : {
83+ id : "chatcmpl-cache" ,
84+ model : "gpt-4o-mini" ,
85+ choices : [ { index : 0 , message : { role : "assistant" , content : "Hello!" } , finish_reason : "stop" } ] ,
86+ usage : {
87+ prompt_tokens : 100 ,
88+ completion_tokens : 5 ,
89+ total_tokens : 105 ,
90+ prompt_tokens_details : { cached_tokens : 75 , cache_write_tokens : 20 }
91+ }
92+ } . to_json
93+ )
94+
95+ chat = RubyLLM . chat ( model : "gpt-4o-mini" )
96+ chat . ask ( "Hi" )
97+
98+ span = EXPORTER . finished_spans . first
99+ assert_equal 75 , span . attributes [ "gen_ai.usage.cache_read.input_tokens" ]
100+ assert_equal 20 , span . attributes [ "gen_ai.usage.cache_creation.input_tokens" ]
101+ end
102+
52103 def test_records_error_on_api_failure
53104 stub_request ( :post , "https://api.openai.com/v1/chat/completions" )
54105 . to_return ( status : 500 , body : "Internal Server Error" )
@@ -170,12 +221,55 @@ def execute(expression:)
170221 assert_equal "execute_tool calculator" , tool_span . name
171222 assert_equal "execute_tool" , tool_span . attributes [ "gen_ai.operation.name" ]
172223 assert_equal "calculator" , tool_span . attributes [ "gen_ai.tool.name" ]
224+ assert_equal "Performs math" , tool_span . attributes [ "gen_ai.tool.description" ]
173225 assert_equal '{"expression":"2+2"}' , tool_span . attributes [ "gen_ai.tool.call.arguments" ]
174226 assert_equal "4" , tool_span . attributes [ "gen_ai.tool.call.result" ]
175227 assert_equal "call_abc123" , tool_span . attributes [ "gen_ai.tool.call.id" ]
176228 assert_equal "function" , tool_span . attributes [ "gen_ai.tool.type" ]
177229 end
178230
231+ def test_records_error_when_tool_raises
232+ boom = Class . new ( RubyLLM ::Tool ) do
233+ def self . name = "boom"
234+ description "Always raises"
235+
236+ def execute
237+ raise ArgumentError , "tool failure"
238+ end
239+ end
240+
241+ stub_request ( :post , "https://api.openai.com/v1/chat/completions" )
242+ . to_return (
243+ status : 200 ,
244+ headers : { "Content-Type" => "application/json" } ,
245+ body : {
246+ id : "chatcmpl-boom" ,
247+ model : "gpt-4o-mini" ,
248+ choices : [ {
249+ index : 0 ,
250+ message : {
251+ role : "assistant" ,
252+ content : nil ,
253+ tool_calls : [ {
254+ id : "call_x" ,
255+ type : "function" ,
256+ function : { name : "boom" , arguments : "{}" }
257+ } ]
258+ } ,
259+ finish_reason : "tool_calls"
260+ } ] ,
261+ usage : { prompt_tokens : 1 , completion_tokens : 1 , total_tokens : 2 }
262+ } . to_json
263+ )
264+
265+ chat = RubyLLM . chat ( model : "gpt-4o-mini" ) . with_tool ( boom )
266+ assert_raises ( ArgumentError ) { chat . ask ( "trigger" ) }
267+
268+ tool_span = EXPORTER . finished_spans . find { |s | s . name . start_with? ( "execute_tool " ) }
269+ assert_equal "ArgumentError" , tool_span . attributes [ "error.type" ]
270+ assert_equal OpenTelemetry ::Trace ::Status ::ERROR , tool_span . status . code
271+ end
272+
179273 def test_does_not_capture_content_by_default
180274 stub_request ( :post , "https://api.openai.com/v1/chat/completions" )
181275 . to_return (
0 commit comments