langsmith-sdk 0.1.1

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.
@@ -0,0 +1,751 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example: Integration with ruby-openai gem
4
+ #
5
+ # This example shows how to integrate LangSmith tracing with the ruby-openai gem.
6
+ # Install: gem install ruby-openai
7
+ #
8
+ # Run with: OPENAI_API_KEY=sk-... LANGSMITH_API_KEY=ls_... ruby examples/openai_integration.rb
9
+
10
+ require_relative "../lib/langsmith"
11
+
12
+ begin
13
+ require "openai"
14
+ require "json"
15
+ rescue LoadError
16
+ puts "This example requires the ruby-openai gem."
17
+ puts "Install with: gem install ruby-openai"
18
+ exit 1
19
+ end
20
+
21
+ # Configure Langsmith
22
+ Langsmith.configure do |config|
23
+ config.api_key = ENV.fetch("LANGSMITH_API_KEY")
24
+ config.tracing_enabled = true
25
+ config.project = "openai-ruby-examples"
26
+ end
27
+
28
+ # Create OpenAI client
29
+ OPENAI_CLIENT = OpenAI::Client.new(access_token: ENV.fetch("OPENAI_API_KEY"))
30
+
31
+ # Wrapper for traced OpenAI chat completions
32
+ module TracedOpenAI
33
+ module_function
34
+
35
+ # Traced chat completion
36
+ def chat(messages:, model: "gpt-4o-mini", temperature: 0.7, **options)
37
+ Langsmith.trace(
38
+ "openai.chat.completions",
39
+ run_type: "llm",
40
+ inputs: { messages: messages, model: model, temperature: temperature }
41
+ ) do |run|
42
+ # Set model for LangSmith to display
43
+ run.set_model(model: model, provider: "openai")
44
+
45
+ # Add request metadata
46
+ run.add_metadata(
47
+ temperature: temperature,
48
+ **options.slice(:max_tokens, :top_p, :frequency_penalty, :presence_penalty)
49
+ )
50
+
51
+ # Make the actual API call
52
+ response = OPENAI_CLIENT.chat(
53
+ parameters: {
54
+ model: model,
55
+ messages: messages,
56
+ temperature: temperature,
57
+ **options
58
+ }
59
+ )
60
+
61
+ # Extract and set token usage (Python SDK uses input_tokens/output_tokens)
62
+ if response["usage"]
63
+ run.set_token_usage(
64
+ input_tokens: response["usage"]["prompt_tokens"],
65
+ output_tokens: response["usage"]["completion_tokens"],
66
+ total_tokens: response["usage"]["total_tokens"]
67
+ )
68
+ end
69
+
70
+ # Add response metadata
71
+ run.add_metadata(
72
+ response_id: response["id"],
73
+ finish_reason: response.dig("choices", 0, "finish_reason")
74
+ )
75
+
76
+ # Return the response
77
+ response
78
+ end
79
+ end
80
+
81
+ # Traced embedding
82
+ def embed(input:, model: "text-embedding-3-small")
83
+ Langsmith.trace(
84
+ "openai.embeddings",
85
+ run_type: "llm",
86
+ inputs: { input: input, model: model }
87
+ ) do |run|
88
+ run.set_model(model: model, provider: "openai")
89
+
90
+ response = OPENAI_CLIENT.embeddings(
91
+ parameters: { model: model, input: input }
92
+ )
93
+
94
+ # Set token usage for embeddings (no output tokens)
95
+ if response["usage"]
96
+ run.set_token_usage(
97
+ input_tokens: response["usage"]["prompt_tokens"],
98
+ total_tokens: response["usage"]["total_tokens"]
99
+ )
100
+ end
101
+
102
+ run.add_metadata(
103
+ dimensions: response.dig("data", 0, "embedding")&.length,
104
+ input_count: Array(input).length
105
+ )
106
+
107
+ response
108
+ end
109
+ end
110
+
111
+ # Traced structured output with JSON schema
112
+ # Uses OpenAI's response_format for guaranteed structured responses
113
+ def structured_output(messages:, schema:, schema_name: "response", model: "gpt-4o-mini", **options)
114
+ Langsmith.trace(
115
+ "openai.chat.structured",
116
+ run_type: "llm",
117
+ inputs: {
118
+ messages: messages,
119
+ model: model,
120
+ schema_name: schema_name,
121
+ schema: schema
122
+ }
123
+ ) do |run|
124
+ run.set_model(model: model, provider: "openai")
125
+ run.add_metadata(structured_output: true, schema_name: schema_name)
126
+ run.add_tags("structured-output", "json-schema")
127
+
128
+ # Build the response_format for structured outputs
129
+ response_format = {
130
+ type: "json_schema",
131
+ json_schema: {
132
+ name: schema_name,
133
+ strict: true,
134
+ schema: schema
135
+ }
136
+ }
137
+
138
+ response = OPENAI_CLIENT.chat(
139
+ parameters: {
140
+ model: model,
141
+ messages: messages,
142
+ response_format: response_format,
143
+ **options
144
+ }
145
+ )
146
+
147
+ # Extract and set token usage
148
+ if response["usage"]
149
+ run.set_token_usage(
150
+ input_tokens: response["usage"]["prompt_tokens"],
151
+ output_tokens: response["usage"]["completion_tokens"],
152
+ total_tokens: response["usage"]["total_tokens"]
153
+ )
154
+ end
155
+
156
+ # Parse the structured response
157
+ content = response.dig("choices", 0, "message", "content")
158
+ parsed = JSON.parse(content, symbolize_names: true)
159
+
160
+ run.add_metadata(
161
+ response_id: response["id"],
162
+ finish_reason: response.dig("choices", 0, "finish_reason")
163
+ )
164
+
165
+ # Return just the parsed result (cleaner output)
166
+ parsed
167
+ end
168
+ end
169
+
170
+ # Traced function calling (tools)
171
+ def function_call(messages:, tools:, model: "gpt-4o-mini", tool_choice: "auto", **options)
172
+ Langsmith.trace(
173
+ "openai.chat.function_call",
174
+ run_type: "llm",
175
+ inputs: {
176
+ messages: messages,
177
+ model: model,
178
+ tools: tools.map { |t| t[:function][:name] }
179
+ }
180
+ ) do |run|
181
+ run.set_model(model: model, provider: "openai")
182
+ run.add_metadata(
183
+ tool_choice: tool_choice,
184
+ available_tools: tools.map { |t| t[:function][:name] }
185
+ )
186
+ run.add_tags("function-calling", "tools")
187
+
188
+ response = OPENAI_CLIENT.chat(
189
+ parameters: {
190
+ model: model,
191
+ messages: messages,
192
+ tools: tools,
193
+ tool_choice: tool_choice,
194
+ **options
195
+ }
196
+ )
197
+
198
+ if response["usage"]
199
+ run.set_token_usage(
200
+ input_tokens: response["usage"]["prompt_tokens"],
201
+ output_tokens: response["usage"]["completion_tokens"],
202
+ total_tokens: response["usage"]["total_tokens"]
203
+ )
204
+ end
205
+
206
+ # Extract tool calls if any
207
+ tool_calls = response.dig("choices", 0, "message", "tool_calls") || []
208
+ parsed_tool_calls = tool_calls.map do |tc|
209
+ {
210
+ id: tc["id"],
211
+ function: tc["function"]["name"],
212
+ arguments: JSON.parse(tc["function"]["arguments"], symbolize_names: true)
213
+ }
214
+ end
215
+
216
+ run.add_metadata(
217
+ response_id: response["id"],
218
+ finish_reason: response.dig("choices", 0, "finish_reason"),
219
+ tool_calls_count: parsed_tool_calls.length
220
+ )
221
+
222
+ # Return just the tool calls (cleaner output)
223
+ parsed_tool_calls
224
+ end
225
+ end
226
+
227
+ # ============================================================================
228
+ # OpenAI Responses API (new agent-focused API)
229
+ # ============================================================================
230
+
231
+ # Traced Responses API call - OpenAI's new simplified API for agents
232
+ def responses(input:, model: "gpt-4o-mini", instructions: nil, tools: nil, **options)
233
+ Langsmith.trace(
234
+ "openai.responses",
235
+ run_type: "llm",
236
+ inputs: { input: input, model: model, instructions: instructions&.slice(0, 200) }
237
+ ) do |run|
238
+ run.set_model(model: model, provider: "openai")
239
+ run.add_metadata(api: "responses")
240
+ run.add_tags("responses-api")
241
+
242
+ params = {
243
+ model: model,
244
+ input: input,
245
+ **options
246
+ }
247
+ params[:instructions] = instructions if instructions
248
+ params[:tools] = tools if tools
249
+
250
+ response = OPENAI_CLIENT.responses.create(parameters: params)
251
+
252
+ # Extract token usage from Responses API format (uses input_tokens/output_tokens)
253
+ if response["usage"]
254
+ run.set_token_usage(
255
+ input_tokens: response["usage"]["input_tokens"],
256
+ output_tokens: response["usage"]["output_tokens"],
257
+ total_tokens: response["usage"]["total_tokens"]
258
+ )
259
+ end
260
+
261
+ # Extract the output text
262
+ output_text = response.dig("output", 0, "content", 0, "text") ||
263
+ response.dig("output_text") ||
264
+ response["output"]
265
+
266
+ run.add_metadata(
267
+ response_id: response["id"],
268
+ status: response["status"]
269
+ )
270
+
271
+ # Return just the output text (cleaner output)
272
+ output_text
273
+ end
274
+ end
275
+
276
+ # Traced Responses API with tool use
277
+ def responses_with_tools(input:, tools:, model: "gpt-4o-mini", instructions: nil, **options)
278
+ Langsmith.trace(
279
+ "openai.responses.with_tools",
280
+ run_type: "chain",
281
+ inputs: { input: input, tools: tools.map { |t| t[:name] } }
282
+ ) do |run|
283
+ run.add_metadata(
284
+ api: "responses",
285
+ tool_count: tools.length
286
+ )
287
+ run.add_tags("responses-api", "tools")
288
+
289
+ # Initial response
290
+ response = Langsmith.trace("responses.initial", run_type: "llm") do |llm_run|
291
+ llm_run.set_model(model: model, provider: "openai")
292
+
293
+ result = OPENAI_CLIENT.responses.create(
294
+ parameters: {
295
+ model: model,
296
+ input: input,
297
+ instructions: instructions,
298
+ tools: tools,
299
+ **options
300
+ }
301
+ )
302
+
303
+ # Responses API uses input_tokens/output_tokens
304
+ if result["usage"]
305
+ llm_run.set_token_usage(
306
+ input_tokens: result["usage"]["input_tokens"],
307
+ output_tokens: result["usage"]["output_tokens"],
308
+ total_tokens: result["usage"]["total_tokens"]
309
+ )
310
+ end
311
+
312
+ result
313
+ end
314
+
315
+ # Check for tool calls in output
316
+ tool_calls = []
317
+ outputs = response["output"] || []
318
+ outputs.each do |output|
319
+ next unless output["type"] == "function_call"
320
+
321
+ tool_calls << {
322
+ id: output["call_id"],
323
+ name: output["name"],
324
+ arguments: JSON.parse(output["arguments"], symbolize_names: true)
325
+ }
326
+ end
327
+
328
+ run.add_metadata(
329
+ tool_calls_count: tool_calls.length
330
+ )
331
+
332
+ # Return just the tool calls (cleaner output)
333
+ tool_calls
334
+ end
335
+ end
336
+
337
+ # Traced streaming chat completion with TTFT (time to first token) tracking
338
+ # Follows Python SDK pattern: adds "new_token" event for first token
339
+ def chat_stream(messages:, model: "gpt-4o-mini", temperature: 0.7, &block)
340
+ Langsmith.trace(
341
+ "openai.chat.completions.stream",
342
+ run_type: "llm",
343
+ inputs: { messages: messages, model: model, streaming: true }
344
+ ) do |run|
345
+ run.set_model(model: model, provider: "openai")
346
+ run.add_metadata(temperature: temperature, streaming: true)
347
+
348
+ full_content = ""
349
+ finish_reason = nil
350
+ first_token_logged = false
351
+ first_token_time = nil
352
+ stream_start_time = Time.now
353
+
354
+ OPENAI_CLIENT.chat(
355
+ parameters: {
356
+ model: model,
357
+ messages: messages,
358
+ temperature: temperature,
359
+ stream: proc do |chunk, _bytesize|
360
+ delta = chunk.dig("choices", 0, "delta", "content")
361
+ if delta
362
+ # Log "new_token" event for first token (Python SDK pattern)
363
+ # LangSmith uses this to calculate time-to-first-token
364
+ unless first_token_logged
365
+ first_token_time = Time.now.utc
366
+ run.add_event(name: "new_token", time: first_token_time, token: delta)
367
+ first_token_logged = true
368
+ end
369
+
370
+ full_content += delta
371
+ block&.call(delta)
372
+ end
373
+
374
+ # Capture finish reason from final chunk
375
+ if (fr = chunk.dig("choices", 0, "finish_reason"))
376
+ finish_reason = fr
377
+ end
378
+ end
379
+ }
380
+ )
381
+
382
+ stream_end_time = Time.now
383
+
384
+ # Calculate TTFT for display
385
+ time_to_first_token = first_token_time ? (first_token_time - stream_start_time) : nil
386
+
387
+ # Estimate tokens for streaming (OpenAI doesn't return usage for streams)
388
+ estimated_input_tokens = messages.sum { |m| (m[:content].to_s.length / 4.0).ceil }
389
+ estimated_output_tokens = (full_content.length / 4.0).ceil
390
+
391
+ # Calculate tokens per second
392
+ generation_time = first_token_time ? (stream_end_time - first_token_time) : (stream_end_time - stream_start_time)
393
+ tokens_per_second = generation_time.positive? ? (estimated_output_tokens / generation_time).round(2) : nil
394
+
395
+ run.set_token_usage(
396
+ input_tokens: estimated_input_tokens,
397
+ output_tokens: estimated_output_tokens,
398
+ total_tokens: estimated_input_tokens + estimated_output_tokens
399
+ )
400
+
401
+ run.add_metadata(
402
+ finish_reason: finish_reason,
403
+ response_length: full_content.length,
404
+ tokens_per_second: tokens_per_second
405
+ )
406
+
407
+ {
408
+ content: full_content,
409
+ finish_reason: finish_reason,
410
+ time_to_first_token: time_to_first_token&.round(3),
411
+ tokens_per_second: tokens_per_second
412
+ }
413
+ end
414
+ end
415
+ end
416
+
417
+ # Example: RAG chain with OpenAI
418
+ class RAGChain
419
+ include Langsmith::Traceable
420
+
421
+ def initialize(knowledge_base:)
422
+ @knowledge_base = knowledge_base
423
+ end
424
+
425
+ traceable run_type: "chain", name: "rag_chain"
426
+ def answer(question)
427
+ # Step 1: Embed the question
428
+ question_embedding = embed_query(question)
429
+
430
+ # Step 2: Retrieve relevant context
431
+ context = retrieve_context(question_embedding)
432
+
433
+ # Step 3: Generate answer
434
+ generate_answer(question, context)
435
+ end
436
+
437
+ private
438
+
439
+ traceable run_type: "llm", name: "embed_query"
440
+ def embed_query(text)
441
+ response = TracedOpenAI.embed(input: text)
442
+ response.dig("data", 0, "embedding")
443
+ end
444
+
445
+ traceable run_type: "retriever", name: "retrieve_context"
446
+ def retrieve_context(embedding)
447
+ # Simulate vector search - in real app, query your vector DB
448
+ Langsmith.current_run&.add_metadata(
449
+ index: "knowledge_base",
450
+ top_k: 3
451
+ )
452
+
453
+ @knowledge_base.first(3)
454
+ end
455
+
456
+ traceable run_type: "llm", name: "generate_answer"
457
+ def generate_answer(question, context)
458
+ messages = [
459
+ {
460
+ role: "system",
461
+ content: "Answer the question based on the following context:\n\n#{context.join("\n\n")}"
462
+ },
463
+ { role: "user", content: question }
464
+ ]
465
+
466
+ response = TracedOpenAI.chat(messages: messages, model: "gpt-4o-mini")
467
+ response.dig("choices", 0, "message", "content")
468
+ end
469
+ end
470
+
471
+ # ============================================================================
472
+ # Run the examples
473
+ # ============================================================================
474
+
475
+ if __FILE__ == $PROGRAM_NAME
476
+ puts "=" * 60
477
+ puts "LangSmith + OpenAI Integration Examples"
478
+ puts "=" * 60
479
+
480
+ # Example 1: Simple chat
481
+ puts "\n1. Simple chat completion:"
482
+ response = TracedOpenAI.chat(
483
+ messages: [{ role: "user", content: "What is Ruby programming language? Be brief." }],
484
+ model: "gpt-3.5-turbo",
485
+ max_tokens: 100
486
+ )
487
+ puts " Response: #{response.dig("choices", 0, "message", "content")}"
488
+ puts " Tokens: #{response.dig("usage", "total_tokens")}"
489
+
490
+ # Example 2: Embeddings
491
+ puts "\n2. Text embeddings:"
492
+ response = TracedOpenAI.embed(input: "Hello, world!")
493
+ puts " Embedding dimensions: #{response.dig("data", 0, "embedding")&.length}"
494
+ puts " Tokens used: #{response.dig("usage", "prompt_tokens")}"
495
+
496
+ # Example 3: Streaming
497
+ puts "\n3. Streaming chat:"
498
+ print " Response: "
499
+ result = TracedOpenAI.chat_stream(
500
+ messages: [{ role: "user", content: "Count from 1 to 5." }],
501
+ model: "gpt-3.5-turbo"
502
+ ) do |chunk|
503
+ print chunk
504
+ end
505
+ puts "\n Finish reason: #{result[:finish_reason]}"
506
+ puts " Time to first token: #{result[:time_to_first_token]}s"
507
+ puts " Tokens/sec: #{result[:tokens_per_second]}"
508
+
509
+ # Example 4: Structured output - Entity extraction
510
+ puts "\n4. Structured output (entity extraction):"
511
+ # Note: OpenAI strict mode requires ALL properties to be in `required`
512
+ entity_schema = {
513
+ type: "object",
514
+ properties: {
515
+ people: {
516
+ type: "array",
517
+ items: {
518
+ type: "object",
519
+ properties: {
520
+ name: { type: "string", description: "Person's full name" },
521
+ role: { type: ["string", "null"], description: "Their role or title, null if unknown" },
522
+ organization: { type: ["string", "null"], description: "Organization, null if unknown" }
523
+ },
524
+ required: %w[name role organization],
525
+ additionalProperties: false
526
+ }
527
+ },
528
+ locations: {
529
+ type: "array",
530
+ items: { type: "string" }
531
+ },
532
+ summary: { type: "string", description: "Brief summary of the text" }
533
+ },
534
+ required: %w[people locations summary],
535
+ additionalProperties: false
536
+ }
537
+
538
+ result = TracedOpenAI.structured_output(
539
+ messages: [
540
+ {
541
+ role: "user",
542
+ content: "Extract entities from: Yukihiro Matsumoto created Ruby in Japan. " \
543
+ "DHH built Rails while at 37signals in Chicago."
544
+ }
545
+ ],
546
+ schema: entity_schema,
547
+ schema_name: "entity_extraction"
548
+ )
549
+ puts " People found: #{result[:people].map { |p| p[:name] }.join(", ")}"
550
+ puts " Locations: #{result[:locations].join(", ")}"
551
+
552
+ # Example 5: Structured output - Classification
553
+ puts "\n5. Structured output (sentiment classification):"
554
+ sentiment_schema = {
555
+ type: "object",
556
+ properties: {
557
+ sentiment: {
558
+ type: "string",
559
+ enum: %w[positive negative neutral mixed],
560
+ description: "Overall sentiment"
561
+ },
562
+ confidence: {
563
+ type: "number",
564
+ description: "Confidence score 0-1"
565
+ },
566
+ key_phrases: {
567
+ type: "array",
568
+ items: { type: "string" },
569
+ description: "Key phrases that indicate the sentiment"
570
+ },
571
+ reasoning: { type: "string", description: "Brief explanation" }
572
+ },
573
+ required: %w[sentiment confidence key_phrases reasoning],
574
+ additionalProperties: false
575
+ }
576
+
577
+ result = TracedOpenAI.structured_output(
578
+ messages: [
579
+ { role: "system", content: "Analyze the sentiment of the given text." },
580
+ { role: "user", content: "I love Ruby! The syntax is beautiful and elegant." }
581
+ ],
582
+ schema: sentiment_schema,
583
+ schema_name: "sentiment_analysis"
584
+ )
585
+ puts " Sentiment: #{result[:sentiment]} (confidence: #{result[:confidence]})"
586
+ puts " Reasoning: #{result[:reasoning]}"
587
+
588
+ # Example 6: Function calling (tools)
589
+ puts "\n6. Function calling:"
590
+ # Note: With strict: true, ALL properties must be in required
591
+ weather_tools = [
592
+ {
593
+ type: "function",
594
+ function: {
595
+ name: "get_weather",
596
+ description: "Get current weather for a location",
597
+ parameters: {
598
+ type: "object",
599
+ properties: {
600
+ location: { type: "string", description: "City name" },
601
+ unit: { type: "string", enum: %w[celsius fahrenheit], description: "Temperature unit" }
602
+ },
603
+ required: %w[location unit],
604
+ additionalProperties: false
605
+ },
606
+ strict: true
607
+ }
608
+ },
609
+ {
610
+ type: "function",
611
+ function: {
612
+ name: "get_forecast",
613
+ description: "Get weather forecast for upcoming days",
614
+ parameters: {
615
+ type: "object",
616
+ properties: {
617
+ location: { type: "string", description: "City name" },
618
+ days: { type: "integer", description: "Number of days (1-7)" }
619
+ },
620
+ required: %w[location days],
621
+ additionalProperties: false
622
+ },
623
+ strict: true
624
+ }
625
+ }
626
+ ]
627
+
628
+ result = TracedOpenAI.function_call(
629
+ messages: [{ role: "user", content: "What's the weather in Tokyo and the 3-day forecast?" }],
630
+ tools: weather_tools
631
+ )
632
+ puts " Tool calls: #{result.length}"
633
+ result.each do |tc|
634
+ puts " - #{tc[:function]}(#{tc[:arguments]})"
635
+ end
636
+
637
+ # Example 7: RAG chain
638
+ puts "\n7. RAG chain:"
639
+ knowledge = [
640
+ "Ruby is a dynamic, interpreted programming language created by Yukihiro Matsumoto.",
641
+ "Rails is a web application framework written in Ruby.",
642
+ "LangSmith provides observability for LLM applications."
643
+ ]
644
+ rag = RAGChain.new(knowledge_base: knowledge)
645
+ answer = rag.answer("What is Ruby?")
646
+ puts " Answer: #{answer}"
647
+
648
+ # Example 8: Responses API (new OpenAI agent API)
649
+ puts "\n8. Responses API (simple):"
650
+ begin
651
+ result = TracedOpenAI.responses(
652
+ input: "What is the capital of France? Answer in one word.",
653
+ model: "gpt-4o-mini"
654
+ )
655
+ puts " Output: #{result}"
656
+ rescue StandardError => e
657
+ puts " (Responses API not available: #{e.message.split("\n").first})"
658
+ end
659
+
660
+ # Example 9: Responses API with tools
661
+ puts "\n9. Responses API with tools:"
662
+ begin
663
+ calculator_tools = [
664
+ {
665
+ type: "function",
666
+ name: "calculate",
667
+ description: "Perform a mathematical calculation",
668
+ parameters: {
669
+ type: "object",
670
+ properties: {
671
+ expression: { type: "string", description: "Math expression to evaluate" }
672
+ },
673
+ required: ["expression"],
674
+ additionalProperties: false
675
+ },
676
+ strict: true
677
+ }
678
+ ]
679
+
680
+ result = TracedOpenAI.responses_with_tools(
681
+ input: "What is 25 * 4?",
682
+ tools: calculator_tools,
683
+ model: "gpt-4o-mini"
684
+ )
685
+ puts " Tool calls: #{result.length}"
686
+ result.each do |tc|
687
+ puts " - #{tc[:name]}(#{tc[:arguments]})"
688
+ end
689
+ rescue StandardError => e
690
+ puts " (Responses API not available: #{e.message.split("\n").first})"
691
+ end
692
+
693
+ # Example 10: Structured output in a traced chain
694
+ puts "\n10. Chained structured extraction:"
695
+ Langsmith.trace("document_analysis_pipeline", run_type: "chain") do |run|
696
+ run.add_metadata(pipeline_version: "1.0")
697
+
698
+ # Step 1: Extract entities
699
+ entities = Langsmith.trace("extract_entities", run_type: "chain") do
700
+ TracedOpenAI.structured_output(
701
+ messages: [{ role: "user", content: "Extract from: OpenAI was founded in San Francisco." }],
702
+ schema: {
703
+ type: "object",
704
+ properties: {
705
+ companies: { type: "array", items: { type: "string" } },
706
+ cities: { type: "array", items: { type: "string" } }
707
+ },
708
+ required: %w[companies cities],
709
+ additionalProperties: false
710
+ },
711
+ schema_name: "simple_entities"
712
+ )
713
+ end
714
+
715
+ # Step 2: Analyze sentiment
716
+ sentiment = Langsmith.trace("analyze_sentiment", run_type: "chain") do
717
+ TracedOpenAI.structured_output(
718
+ messages: [{ role: "user", content: "Sentiment of: This is amazing news!" }],
719
+ schema: {
720
+ type: "object",
721
+ properties: {
722
+ sentiment: { type: "string", enum: %w[positive negative neutral] },
723
+ score: { type: "number" }
724
+ },
725
+ required: %w[sentiment score],
726
+ additionalProperties: false
727
+ },
728
+ schema_name: "quick_sentiment"
729
+ )
730
+ end
731
+
732
+ run.add_metadata(
733
+ entities_found: entities[:companies].length + entities[:cities].length,
734
+ sentiment_result: sentiment[:sentiment]
735
+ )
736
+
737
+ { entities: entities, sentiment: sentiment }
738
+ end
739
+ puts " Pipeline complete!"
740
+
741
+ # Flush traces
742
+ Langsmith.shutdown
743
+
744
+ puts "\n" + "=" * 60
745
+ puts "Done! Check LangSmith for detailed traces with:"
746
+ puts "- JSON schemas captured in inputs"
747
+ puts "- Parsed structured outputs"
748
+ puts "- Function/tool call details"
749
+ puts "- Full token usage"
750
+ puts "=" * 60
751
+ end