diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffa789c2..749a9bcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/openai-ruby' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -33,6 +34,7 @@ jobs: timeout-minutes: 10 name: test runs-on: ${{ github.repository == 'stainless-sdks/openai-ruby' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 - name: Set up Ruby diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f7014c35..a7130553 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.0" + ".": "0.12.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2f55495d..60823b73 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 109 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-a473967d1766dc155994d932fbc4a5bcbd1c140a37c20d0a4065e1bf0640536d.yml openapi_spec_hash: 67cdc62b0d6c8b1de29b7dc54b265749 -config_hash: e74d6791681e3af1b548748ff47a22c2 +config_hash: 7b53f96f897ca1b3407a5341a6f820db diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b923301..50bed6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.12.0 (2025-07-03) + +Full Changelog: [v0.11.0...v0.12.0](https://github.com/openai/openai-ruby/compare/v0.11.0...v0.12.0) + +### Features + +* ensure partial jsons in structured ouput are handled gracefully ([#740](https://github.com/openai/openai-ruby/issues/740)) ([5deec70](https://github.com/openai/openai-ruby/commit/5deec708bad1ceb1a03e9aa65f737e3f89ce6455)) +* responses streaming helpers ([#721](https://github.com/openai/openai-ruby/issues/721)) ([c2f4270](https://github.com/openai/openai-ruby/commit/c2f42708e41492f1c22886735079973510fb2789)) + + +### Chores + +* **ci:** only run for pushes and fork pull requests ([97538e2](https://github.com/openai/openai-ruby/commit/97538e266f6f9a0e09669453539ee52ca56f4f59)) +* **internal:** allow streams to also be unwrapped on a per-row basis ([49bdadf](https://github.com/openai/openai-ruby/commit/49bdadfc0d3400664de0c8e7cfd59879faec45b8)) +* **internal:** minor refactoring of json helpers ([#744](https://github.com/openai/openai-ruby/issues/744)) ([f13edee](https://github.com/openai/openai-ruby/commit/f13edee16325be04335443cb886a7c2024155fd9)) + ## 0.11.0 (2025-06-26) Full Changelog: [v0.10.0...v0.11.0](https://github.com/openai/openai-ruby/compare/v0.10.0...v0.11.0) diff --git a/Gemfile.lock b/Gemfile.lock index 45c8a9a4..f8710098 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,7 +11,7 @@ GIT PATH remote: . specs: - openai (0.11.0) + openai (0.12.0) connection_pool GEM diff --git a/README.md b/README.md index c2869dcd..42099fb9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ To use this gem, install via Bundler by adding the following to your application ```ruby -gem "openai", "~> 0.11.0" +gem "openai", "~> 0.12.0" ``` @@ -42,16 +42,14 @@ puts(chat_completion) We provide support for streaming responses using Server-Sent Events (SSE). -**coming soon:** `openai.chat.completions.stream` will soon come with Python SDK-style higher-level streaming responses support. - ```ruby -stream = openai.chat.completions.stream_raw( - messages: [{role: "user", content: "Say this is a test"}], +stream = openai.responses.stream( + input: "Write a haiku about OpenAI.", model: :"gpt-4.1" ) -stream.each do |completion| - puts(completion) +stream.each do |event| + puts(event.type) end ``` diff --git a/examples/responses/streaming_basic.rb b/examples/responses/streaming_basic.rb new file mode 100755 index 00000000..1606ba07 --- /dev/null +++ b/examples/responses/streaming_basic.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# typed: strict + +require_relative "../../lib/openai" + +client = OpenAI::Client.new + +stream = client.responses.stream( + input: "Write a haiku about OpenAI.", + model: "gpt-4o-2024-08-06" +) + +stream.each do |event| + case event + when OpenAI::Streaming::ResponseTextDeltaEvent + print(event.delta) + when OpenAI::Streaming::ResponseTextDoneEvent + puts("\n--------------------------") + when OpenAI::Streaming::ResponseCompletedEvent + puts("Response completed! (response id: #{event.response.id})") + end +end diff --git a/examples/responses/streaming_previous_response.rb b/examples/responses/streaming_previous_response.rb new file mode 100755 index 00000000..5187fd21 --- /dev/null +++ b/examples/responses/streaming_previous_response.rb @@ -0,0 +1,79 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../../lib/openai" + +# This example demonstrates how to resume a streaming response. + +client = OpenAI::Client.new + +# Request 1: Create a new streaming response with store=true +puts "Creating a new streaming response..." +stream = client.responses.stream( + model: "o4-mini", + input: "Tell me a short story about a robot learning to paint.", + instructions: "You are a creative storyteller.", + background: true +) + +events = [] +response_id = "" + +stream.each do |event| + events << event + puts "Event from initial stream: #{event.type} (seq: #{event.sequence_number})" + case event + + when OpenAI::Models::Responses::ResponseCreatedEvent + response_id = event.response.id if response_id.empty? + puts("Captured response ID: #{response_id}") + end + + # Simulate stopping after a few events + if events.length >= 5 + puts "Terminating after #{events.length} events" + break + end +end + +stream.close + +puts +puts "Collected #{events.length} events" +puts "Response ID: #{response_id}" +puts "Last event sequence number: #{events.last.sequence_number}.\n" + +# Give the background response some time to process more events. +puts "Waiting a moment for the background response to progress...\n" +sleep(2) + +# Request 2: Resume the stream using the captured response_id. +puts "Resuming stream from sequence #{events.last.sequence_number}..." + +resumed_stream = client.responses.stream( + previous_response_id: response_id, + starting_after: events.last.sequence_number +) + +resumed_events = [] +resumed_stream.each do |event| + resumed_events << event + puts "Event from resumed stream: #{event.type} (seq: #{event.sequence_number})" + # Stop when we get the completed event or collect enough events. + if event.is_a?(OpenAI::Models::Responses::ResponseCompletedEvent) + puts "Response completed!" + break + end + + break if resumed_events.length >= 10 +end + +puts "\nCollected #{resumed_events.length} additional events" + +# Show that we properly resumed from where we left off. +if resumed_events.any? + first_resumed_event = resumed_events.first + last_initial_event = events.last + puts "First resumed event sequence: #{first_resumed_event.sequence_number}" + puts "Should be greater than last initial event: #{last_initial_event.sequence_number}" +end diff --git a/examples/responses/streaming_structured_outputs.rb b/examples/responses/streaming_structured_outputs.rb new file mode 100755 index 00000000..8188140e --- /dev/null +++ b/examples/responses/streaming_structured_outputs.rb @@ -0,0 +1,46 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../../lib/openai" + +# Defining structured output models. +class Step < OpenAI::BaseModel + required :explanation, String + required :output, String +end + +class MathResponse < OpenAI::BaseModel + required :steps, OpenAI::ArrayOf[Step] + required :final_answer, String +end + +client = OpenAI::Client.new + +stream = client.responses.stream( + input: "solve 8x + 31 = 2", + model: "gpt-4o-2024-08-06", + text: MathResponse +) + +stream.each do |event| + case event + when OpenAI::Streaming::ResponseTextDeltaEvent + print(event.delta) + when OpenAI::Streaming::ResponseTextDoneEvent + puts + puts("--- Parsed object ---") + pp(event.parsed) + end +end + +response = stream.get_final_response + +puts +puts("----- parsed outputs from final response -----") +response + .output + .flat_map { _1.content } + .each do |content| + # parsed is an instance of `MathResponse` + pp(content.parsed) + end diff --git a/examples/responses/streaming_text.rb b/examples/responses/streaming_text.rb new file mode 100755 index 00000000..34d95bcb --- /dev/null +++ b/examples/responses/streaming_text.rb @@ -0,0 +1,21 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# typed: strong + +require_relative "../../lib/openai" + +client = OpenAI::Client.new + +stream = client.responses.stream( + input: "Write a haiku about OpenAI.", + model: "gpt-4o-2024-08-06" +) + +stream.text.each do |text| + print(text) +end + +puts + +# Get all of the text that was streamed with .get_output_text +puts "Character count: #{stream.get_output_text.length}" diff --git a/examples/responses/streaming_tools.rb b/examples/responses/streaming_tools.rb new file mode 100755 index 00000000..2dc4cb2e --- /dev/null +++ b/examples/responses/streaming_tools.rb @@ -0,0 +1,63 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# typed: true + +require_relative "../../lib/openai" + +class DynamicValue < OpenAI::BaseModel + required :column_name, String +end + +class Condition < OpenAI::BaseModel + required :column, String + required :operator, OpenAI::EnumOf[:eq, :gt, :lt, :le, :ge, :ne] + required :value, OpenAI::UnionOf[String, Integer, DynamicValue] +end + +# you can assign `OpenAI::{...}` schema specifiers to a constant +Columns = OpenAI::EnumOf[ + :id, + :status, + :expected_delivery_date, + :delivered_at, + :shipped_at, + :ordered_at, + :canceled_at +] + +class Query < OpenAI::BaseModel + required :table_name, OpenAI::EnumOf[:orders, :customers, :products] + required :columns, OpenAI::ArrayOf[Columns] + required :conditions, OpenAI::ArrayOf[Condition] + required :order_by, OpenAI::EnumOf[:asc, :desc] +end + +client = OpenAI::Client.new + +stream = client.responses.stream( + model: "gpt-4o-2024-08-06", + input: "look up all my orders in november of last year that were fulfilled but not delivered on time", + tools: [Query] +) + +stream.each do |event| + case event + when OpenAI::Streaming::ResponseFunctionCallArgumentsDeltaEvent + puts("delta: #{event.delta}") + puts("snapshot: #{event.snapshot}") + end +end + +response = stream.get_final_response + +puts +puts("----- parsed outputs from final response -----") +response + .output + .each do |output| + case output + when OpenAI::Models::Responses::ResponseFunctionToolCall + # parsed is an instance of `Query` + pp(output.parsed) + end + end diff --git a/helpers.md b/helpers.md new file mode 100644 index 00000000..3ba47c6a --- /dev/null +++ b/helpers.md @@ -0,0 +1,157 @@ +# Streaming Helpers + +## Responses API + +```ruby +stream = client.responses.stream( + input: "Tell me a story about programming", + model: "gpt-4o" +) + +stream.each do |event| + case event + when OpenAI::Streaming::ResponseTextDeltaEvent + print(event.delta) + end +end + +puts +``` + +`client.responses.stream` returns a `ResponseStream` that is an `Enumerable` emitting events. + +The stream will be cancelled when the block exits but you can also close it prematurely by calling `stream.close`. + +See an example of streaming helpers in action in [`examples/responses/streaming.rb`](examples/responses/streaming.rb). + +### Events + +The events listed here are just the event types that the SDK extends, for a full list of the events returned by the API, see [these docs](https://platform.openai.com/docs/api-reference/responses/streaming). + +```ruby +require "openai" + +client = OpenAI::Client.new + +stream = client.responses.stream( + input: "Write a haiku", + model: "gpt-4o" +) + +stream.each do |event| + case event + when OpenAI::Streaming::ResponseTextDeltaEvent + print(event.delta) + when OpenAI::Streaming::ResponseTextDoneEvent + puts("\n\nText completed: #{event.text}") + when OpenAI::Streaming::ResponseCompletedEvent + puts("\nResponse ID: #{event.response.id}") + end +end + +# you can still get the accumulated final response outside of +# the block, as long as the entire stream was consumed +# inside of the block +final_response = stream.get_final_response +puts("Final response: #{final_response.to_json}") +``` + +#### `ResponseTextDeltaEvent` + +This event is yielded whenever a text content delta is returned by the API & includes the delta and the accumulated snapshot, e.g. + +```ruby +when OpenAI::Streaming::ResponseTextDeltaEvent + event.type # :"response.output_text.delta" + event.delta # " world" + event.snapshot # "Hello world" +``` + +#### `ResponseTextDoneEvent` + +This event is fired when text generation is complete & includes the full text and parsed content if using structured outputs. + +```ruby +when OpenAI::Streaming::ResponseTextDoneEvent + event.type # :"response.output_text.done" + event.text # "Hello world" + event.parsed # Your parsed model instance (when using text) +``` + +#### `ResponseFunctionCallArgumentsDeltaEvent` + +This event is yielded whenever function call arguments are being streamed & includes the delta and accumulated snapshot, e.g. + +```ruby +when OpenAI::Streaming::ResponseFunctionCallArgumentsDeltaEvent + event.type # :"response.function_call_arguments.delta" + event.delta # '": "San Francisco"}' + event.snapshot # '{"location": "San Francisco"}' +``` + +#### `ResponseCompletedEvent` + +The event is fired when a full Response object has been accumulated. + +```ruby +when OpenAI::Streaming::ResponseCompletedEvent + event.type # :"response.completed" + event.response # ParsedResponse object with all outputs +``` + +### Methods + +Public Methods on the ResponseStream class: + +#### `.text` + +Returns an enumerable that yields the text deltas from the stream. + +#### `.get_output_text` + +Blocks until the stream has been read to completion and returns all `text` content deltas concatenated together. + +#### `.get_final_response` + +Blocks until the stream has been read to completion and returns the accumulated `ParsedResponse` object. + +#### `.until_done` + +Blocks until the stream has been read to completion. + +#### `.close` + +Aborts the request. + +### Structured Outputs + +The Responses API supports structured outputs via the `text` parameter: + +```ruby +class Haiku < OpenAI::BaseModel + field :first_line, String + field :second_line, String + field :third_line, String +end + +stream = client.responses.stream( + input: "Write a haiku about Ruby", + model: "gpt-4o", + text: Haiku +) + +stream.each do |event| + case event + when OpenAI::Streaming::ResponseTextDoneEvent + haiku = event.parsed + puts("First line: #{haiku.first_line}") + puts("Second line: #{haiku.second_line}") + puts("Third line: #{haiku.third_line}") + end +end +``` + +When `text` is provided: +- The model is instructed to output valid JSON matching your schema +- Text content is automatically parsed into instances of your type +- Parsed objects are available via `event.parsed` on text done events diff --git a/lib/openai.rb b/lib/openai.rb index ce2c2013..4141ab99 100644 --- a/lib/openai.rb +++ b/lib/openai.rb @@ -56,6 +56,7 @@ require_relative "openai/helpers/structured_output/union_of" require_relative "openai/helpers/structured_output/array_of" require_relative "openai/helpers/structured_output/base_model" +require_relative "openai/helpers/structured_output/parsed_json" require_relative "openai/helpers/structured_output" require_relative "openai/structured_output" require_relative "openai/models/reasoning_effort" @@ -539,3 +540,6 @@ require_relative "openai/resources/vector_stores/file_batches" require_relative "openai/resources/vector_stores/files" require_relative "openai/resources/webhooks" +require_relative "openai/helpers/streaming/events" +require_relative "openai/helpers/streaming/response_stream" +require_relative "openai/streaming" diff --git a/lib/openai/helpers/streaming/events.rb b/lib/openai/helpers/streaming/events.rb new file mode 100644 index 00000000..49096d0d --- /dev/null +++ b/lib/openai/helpers/streaming/events.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module OpenAI + module Helpers + module Streaming + class ResponseTextDeltaEvent < OpenAI::Models::Responses::ResponseTextDeltaEvent + required :snapshot, String + end + + class ResponseTextDoneEvent < OpenAI::Models::Responses::ResponseTextDoneEvent + optional :parsed, Object + end + + class ResponseFunctionCallArgumentsDeltaEvent < OpenAI::Models::Responses::ResponseFunctionCallArgumentsDeltaEvent + required :snapshot, String + end + + class ResponseCompletedEvent < OpenAI::Models::Responses::ResponseCompletedEvent + required :response, OpenAI::Models::Responses::Response + end + end + end +end diff --git a/lib/openai/helpers/streaming/response_stream.rb b/lib/openai/helpers/streaming/response_stream.rb new file mode 100644 index 00000000..0aa3eb0f --- /dev/null +++ b/lib/openai/helpers/streaming/response_stream.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require_relative "events" + +module OpenAI + module Helpers + module Streaming + class ResponseStream + include OpenAI::Internal::Type::BaseStream + + def initialize(raw_stream:, text_format: nil, starting_after: nil) + @text_format = text_format + @starting_after = starting_after + @raw_stream = raw_stream + @iterator = iterator + @state = ResponseStreamState.new( + text_format: text_format + ) + end + + def until_done + each {} # rubocop:disable Lint/EmptyBlock + self + end + + def text + OpenAI::Internal::Util.chain_fused(@iterator) do |yielder| + @iterator.each do |event| + case event + when OpenAI::Streaming::ResponseTextDeltaEvent + yielder << event.delta + end + end + end + end + + def get_final_response + until_done + response = @state.completed_response + raise RuntimeError.new("Didn't receive a 'response.completed' event") unless response + response + end + + def get_output_text + response = get_final_response + text_parts = [] + + response.output.each do |output| + next unless output.type == :message + + output.content.each do |content| + next unless content.type == :output_text + text_parts << content.text + end + end + + text_parts.join + end + + private + + def iterator + @iterator ||= OpenAI::Internal::Util.chain_fused(@raw_stream) do |y| + @raw_stream.each do |raw_event| + events_to_yield = @state.handle_event(raw_event) + events_to_yield.each do |event| + if @starting_after.nil? || event.sequence_number > @starting_after + y << event + end + end + end + end + end + end + + class ResponseStreamState + attr_reader :completed_response + + def initialize(text_format:) + @current_snapshot = nil + @completed_response = nil + @text_format = text_format + end + + def handle_event(event) + @current_snapshot = accumulate_event( + event: event, + current_snapshot: @current_snapshot + ) + + events_to_yield = [] + + case event + when OpenAI::Models::Responses::ResponseTextDeltaEvent + output = @current_snapshot.output[event.output_index] + assert_type(output, :message) + + content = output.content[event.content_index] + assert_type(content, :output_text) + + events_to_yield << OpenAI::Streaming::ResponseTextDeltaEvent.new( + content_index: event.content_index, + delta: event.delta, + item_id: event.item_id, + output_index: event.output_index, + sequence_number: event.sequence_number, + type: event.type, + snapshot: content.text + ) + + when OpenAI::Models::Responses::ResponseTextDoneEvent + output = @current_snapshot.output[event.output_index] + assert_type(output, :message) + + content = output.content[event.content_index] + assert_type(content, :output_text) + + parsed = parse_structured_text(content.text) + + events_to_yield << OpenAI::Streaming::ResponseTextDoneEvent.new( + content_index: event.content_index, + item_id: event.item_id, + output_index: event.output_index, + sequence_number: event.sequence_number, + text: event.text, + type: event.type, + parsed: parsed + ) + + when OpenAI::Models::Responses::ResponseFunctionCallArgumentsDeltaEvent + output = @current_snapshot.output[event.output_index] + assert_type(output, :function_call) + + events_to_yield << OpenAI::Streaming::ResponseFunctionCallArgumentsDeltaEvent.new( + delta: event.delta, + item_id: event.item_id, + output_index: event.output_index, + sequence_number: event.sequence_number, + type: event.type, + snapshot: output.arguments + ) + + when OpenAI::Models::Responses::ResponseCompletedEvent + events_to_yield << OpenAI::Streaming::ResponseCompletedEvent.new( + sequence_number: event.sequence_number, + type: event.type, + response: event.response + ) + + else + # Pass through other events unchanged. + events_to_yield << event + end + + events_to_yield + end + + def accumulate_event(event:, current_snapshot:) + if current_snapshot.nil? + unless event.is_a?(OpenAI::Models::Responses::ResponseCreatedEvent) + raise "Expected first event to be response.created" + end + + # Use the converter to create a new, isolated copy of the response object. + # This ensures proper type validation and prevents shared object references. + return OpenAI::Internal::Type::Converter.coerce( + OpenAI::Models::Responses::Response, + event.response + ) + end + + case event + when OpenAI::Models::Responses::ResponseOutputItemAddedEvent + current_snapshot.output.push(event.item) + + when OpenAI::Models::Responses::ResponseContentPartAddedEvent + output = current_snapshot.output[event.output_index] + if output && output.type == :message + output.content.push(event.part) + current_snapshot.output[event.output_index] = output + end + + when OpenAI::Models::Responses::ResponseTextDeltaEvent + output = current_snapshot.output[event.output_index] + if output && output.type == :message + content = output.content[event.content_index] + if content && content.type == :output_text + content.text += event.delta + output.content[event.content_index] = content + current_snapshot.output[event.output_index] = output + end + end + + when OpenAI::Models::Responses::ResponseFunctionCallArgumentsDeltaEvent + output = current_snapshot.output[event.output_index] + if output && output.type == :function_call + output.arguments = (output.arguments || "") + event.delta + current_snapshot.output[event.output_index] = output + end + + when OpenAI::Models::Responses::ResponseCompletedEvent + @completed_response = event.response + end + + current_snapshot + end + + private + + def assert_type(object, expected_type) + return if object && object.type == expected_type + actual_type = object ? object.type : "nil" + raise "Invalid state: expected #{expected_type} but got #{actual_type}" + end + + def parse_structured_text(text) + return nil unless @text_format && text + + begin + parsed = JSON.parse(text, symbolize_names: true) + OpenAI::Internal::Type::Converter.coerce(@text_format, parsed) + rescue JSON::ParserError => e + raise RuntimeError.new( + "Failed to parse structured text as JSON for #{@text_format}: #{e.message}. " \ + "Raw text: #{text.inspect}" + ) + end + end + end + end + end +end diff --git a/lib/openai/helpers/structured_output/parsed_json.rb b/lib/openai/helpers/structured_output/parsed_json.rb new file mode 100644 index 00000000..f9c85bfc --- /dev/null +++ b/lib/openai/helpers/structured_output/parsed_json.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module OpenAI + module Helpers + module StructuredOutput + # @abstract + # + # Like OpenAI::Internal::Type::Unknown, but for parsed JSON values, which can be incomplete or malformed. + class ParsedJson < OpenAI::Internal::Type::Unknown + class << self + # @api private + # + # No coercion needed for Unknown type. + # + # @param value [Object] + # + # @param state [Hash{Symbol=>Object}] . + # + # @option state [Boolean] :translate_names + # + # @option state [Boolean] :strictness + # + # @option state [Hash{Symbol=>Object}] :exactness + # + # @option state [Class] :error + # + # @option state [Integer] :branched + # + # @return [Object] + def coerce(value, state:) + (state[:error] = value) if value.is_a?(StandardError) + + super + end + end + end + end + end +end diff --git a/lib/openai/internal/stream.rb b/lib/openai/internal/stream.rb index 2d3b9ac3..3908f10f 100644 --- a/lib/openai/internal/stream.rb +++ b/lib/openai/internal/stream.rb @@ -47,7 +47,8 @@ class Stream message: message ) in decoded - y << OpenAI::Internal::Type::Converter.coerce(@model, decoded) + unwrapped = OpenAI::Internal::Util.dig(decoded, @unwrap) + y << OpenAI::Internal::Type::Converter.coerce(@model, unwrapped) end else end diff --git a/lib/openai/internal/transport/base_client.rb b/lib/openai/internal/transport/base_client.rb index 8e6f703c..6b8c2d3a 100644 --- a/lib/openai/internal/transport/base_client.rb +++ b/lib/openai/internal/transport/base_client.rb @@ -471,6 +471,7 @@ def request(req) self.class.validate!(req) model = req.fetch(:model) { OpenAI::Internal::Type::Unknown } opts = req[:options].to_h + unwrap = req[:unwrap] OpenAI::RequestOptions.validate!(opts) request = build_request(req.except(:options), opts) url = request.fetch(:url) @@ -487,11 +488,18 @@ def request(req) decoded = OpenAI::Internal::Util.decode_content(response, stream: stream) case req in {stream: Class => st} - st.new(model: model, url: url, status: status, response: response, stream: decoded) + st.new( + model: model, + url: url, + status: status, + response: response, + unwrap: unwrap, + stream: decoded + ) in {page: Class => page} page.new(client: self, req: req, headers: response, page_data: decoded) else - unwrapped = OpenAI::Internal::Util.dig(decoded, req[:unwrap]) + unwrapped = OpenAI::Internal::Util.dig(decoded, unwrap) OpenAI::Internal::Type::Converter.coerce(model, unwrapped) end end diff --git a/lib/openai/internal/type/base_stream.rb b/lib/openai/internal/type/base_stream.rb index f1b1c8ff..3ebdf248 100644 --- a/lib/openai/internal/type/base_stream.rb +++ b/lib/openai/internal/type/base_stream.rb @@ -64,12 +64,14 @@ def to_enum = @iterator # @param url [URI::Generic] # @param status [Integer] # @param response [Net::HTTPResponse] + # @param unwrap [Symbol, Integer, Array, Proc] # @param stream [Enumerable] - def initialize(model:, url:, status:, response:, stream:) + def initialize(model:, url:, status:, response:, unwrap:, stream:) @model = model @url = url @status = status @response = response + @unwrap = unwrap @stream = stream @iterator = iterator diff --git a/lib/openai/models/chat/chat_completion_message.rb b/lib/openai/models/chat/chat_completion_message.rb index 444bb7f7..97bd8c64 100644 --- a/lib/openai/models/chat/chat_completion_message.rb +++ b/lib/openai/models/chat/chat_completion_message.rb @@ -14,7 +14,7 @@ class ChatCompletionMessage < OpenAI::Internal::Type::BaseModel # The parsed contents of the message, if JSON schema is specified. # # @return [Object, nil] - optional :parsed, OpenAI::Internal::Type::Unknown + optional :parsed, OpenAI::StructuredOutput::ParsedJson # @!attribute refusal # The refusal message generated by the model. diff --git a/lib/openai/models/chat/chat_completion_message_tool_call.rb b/lib/openai/models/chat/chat_completion_message_tool_call.rb index eab6d4a4..a9cc5e74 100644 --- a/lib/openai/models/chat/chat_completion_message_tool_call.rb +++ b/lib/openai/models/chat/chat_completion_message_tool_call.rb @@ -44,7 +44,7 @@ class Function < OpenAI::Internal::Type::BaseModel # The parsed contents of the arguments. # # @return [Object, nil] - required :parsed, OpenAI::Internal::Type::Unknown + required :parsed, OpenAI::StructuredOutput::ParsedJson # @!attribute name # The name of the function to call. diff --git a/lib/openai/models/responses/response_function_tool_call.rb b/lib/openai/models/responses/response_function_tool_call.rb index fd7afc91..052f8ed4 100644 --- a/lib/openai/models/responses/response_function_tool_call.rb +++ b/lib/openai/models/responses/response_function_tool_call.rb @@ -14,7 +14,7 @@ class ResponseFunctionToolCall < OpenAI::Internal::Type::BaseModel # The parsed contents of the arguments. # # @return [Object, nil] - required :parsed, OpenAI::Internal::Type::Unknown + required :parsed, OpenAI::StructuredOutput::ParsedJson # @!attribute call_id # The unique ID of the function tool call generated by the model. diff --git a/lib/openai/models/responses/response_output_text.rb b/lib/openai/models/responses/response_output_text.rb index 097479d9..bb615db1 100644 --- a/lib/openai/models/responses/response_output_text.rb +++ b/lib/openai/models/responses/response_output_text.rb @@ -23,7 +23,7 @@ class ResponseOutputText < OpenAI::Internal::Type::BaseModel # The parsed contents of the output, if JSON schema is specified. # # @return [Object, nil] - optional :parsed, OpenAI::Internal::Type::Unknown + optional :parsed, OpenAI::StructuredOutput::ParsedJson # @!attribute type # The type of the output text. Always `output_text`. diff --git a/lib/openai/resources/chat/completions.rb b/lib/openai/resources/chat/completions.rb index 5c14fdc1..0991d911 100644 --- a/lib/openai/resources/chat/completions.rb +++ b/lib/openai/resources/chat/completions.rb @@ -104,7 +104,6 @@ def create(params) raise ArgumentError.new(message) end - # rubocop:disable Layout/LineLength model = nil tool_models = {} case parsed @@ -157,11 +156,16 @@ def create(params) else end + # rubocop:disable Metrics/BlockLength unwrap = ->(raw) do if model.is_a?(OpenAI::StructuredOutput::JsonSchemaConverter) raw[:choices]&.each do |choice| message = choice.fetch(:message) - parsed = JSON.parse(message.fetch(:content), symbolize_names: true) + begin + parsed = JSON.parse(message.fetch(:content), symbolize_names: true) + rescue JSON::ParserError => e + parsed = e + end coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed) message.store(:parsed, coerced) end @@ -171,7 +175,11 @@ def create(params) func = tool_call.fetch(:function) next if (model = tool_models[func.fetch(:name)]).nil? - parsed = JSON.parse(func.fetch(:arguments), symbolize_names: true) + begin + parsed = JSON.parse(func.fetch(:arguments), symbolize_names: true) + rescue JSON::ParserError => e + parsed = e + end coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed) func.store(:parsed, coerced) end @@ -179,7 +187,7 @@ def create(params) raw end - # rubocop:enable Layout/LineLength + # rubocop:enable Metrics/BlockLength @client.request( method: :post, diff --git a/lib/openai/resources/responses.rb b/lib/openai/resources/responses.rb index 44538db5..be93e4e3 100644 --- a/lib/openai/resources/responses.rb +++ b/lib/openai/resources/responses.rb @@ -81,81 +81,12 @@ def create(params = {}) raise ArgumentError.new(message) end - model = nil - tool_models = {} - case parsed - in {text: OpenAI::StructuredOutput::JsonSchemaConverter => model} - parsed.update( - text: { - format: { - type: :json_schema, - strict: true, - name: model.name.split("::").last, - schema: model.to_json_schema - } - } - ) - in {text: {format: OpenAI::StructuredOutput::JsonSchemaConverter => model}} - parsed.fetch(:text).update( - format: { - type: :json_schema, - strict: true, - name: model.name.split("::").last, - schema: model.to_json_schema - } - ) - in {text: {format: {type: :json_schema, schema: OpenAI::StructuredOutput::JsonSchemaConverter => model}}} - parsed.dig(:text, :format).store(:schema, model.to_json_schema) - in {tools: Array => tools} - mapped = tools.map do |tool| - case tool - in OpenAI::StructuredOutput::JsonSchemaConverter - name = tool.name.split("::").last - tool_models.store(name, tool) - { - type: :function, - strict: true, - name: name, - parameters: tool.to_json_schema - } - in {type: :function, parameters: OpenAI::StructuredOutput::JsonSchemaConverter => params} - func = tool.fetch(:function) - name = func[:name] ||= params.name.split("::").last - tool_models.store(name, params) - func.update(parameters: params.to_json_schema) - tool - else - tool - end - end - tools.replace(mapped) - else - end + model, tool_models = get_structured_output_models(parsed) unwrap = ->(raw) do - if model.is_a?(OpenAI::StructuredOutput::JsonSchemaConverter) - raw[:output] - &.flat_map do |output| - next [] unless output[:type] == "message" - output[:content].to_a - end - &.each do |content| - next unless content[:type] == "output_text" - parsed = JSON.parse(content.fetch(:text), symbolize_names: true) - coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed) - content.store(:parsed, coerced) - end - end - raw[:output]&.each do |output| - next unless output[:type] == "function_call" - next if (model = tool_models[output.fetch(:name)]).nil? - parsed = JSON.parse(output.fetch(:arguments), symbolize_names: true) - coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed) - output.store(:parsed, coerced) - end - - raw + parse_structured_outputs!(raw, model, tool_models) end + @client.request( method: :post, path: "responses", @@ -166,8 +97,112 @@ def create(params = {}) ) end - def stream - raise NotImplementedError.new("higher level helpers are coming soon!") + # See {OpenAI::Resources::Responses#create} for non-streaming counterpart. + # + # Some parameter documentations has been truncated, see + # {OpenAI::Models::Responses::ResponseCreateParams} for more details. + # + # Creates a model response. Provide + # [text](https://platform.openai.com/docs/guides/text) or + # [image](https://platform.openai.com/docs/guides/images) inputs to generate + # [text](https://platform.openai.com/docs/guides/text) or + # [JSON](https://platform.openai.com/docs/guides/structured-outputs) outputs. Have + # the model call your own + # [custom code](https://platform.openai.com/docs/guides/function-calling) or use + # built-in [tools](https://platform.openai.com/docs/guides/tools) like + # [web search](https://platform.openai.com/docs/guides/tools-web-search) or + # [file search](https://platform.openai.com/docs/guides/tools-file-search) to use + # your own data as input for the model's response. + # + # @overload stream_raw(input:, model:, background: nil, include: nil, instructions: nil, max_output_tokens: nil, metadata: nil, parallel_tool_calls: nil, previous_response_id: nil, prompt: nil, reasoning: nil, service_tier: nil, store: nil, temperature: nil, text: nil, tool_choice: nil, tools: nil, top_p: nil, truncation: nil, user: nil, request_options: {}) + # + # @param input [String, Array] Text, image, or file inputs to the model, used to generate a response. + # + # @param model [String, Symbol, OpenAI::Models::ChatModel, OpenAI::Models::ResponsesModel::ResponsesOnlyModel] Model ID used to generate the response, like `gpt-4o` or `o3`. OpenAI + # + # @param background [Boolean, nil] Whether to run the model response in the background. + # + # @param include [Array, nil] Specify additional output data to include in the model response. Currently + # + # @param instructions [String, nil] A system (or developer) message inserted into the model's context. + # + # @param max_output_tokens [Integer, nil] An upper bound for the number of tokens that can be generated for a response, in + # + # @param metadata [Hash{Symbol=>String}, nil] Set of 16 key-value pairs that can be attached to an object. This can be + # + # @param parallel_tool_calls [Boolean, nil] Whether to allow the model to run tool calls in parallel. + # + # @param previous_response_id [String, nil] The unique ID of the previous response to the model. Use this to resume streams from a given response. + # + # @param prompt [OpenAI::Models::Responses::ResponsePrompt, nil] Reference to a prompt template and its variables. + # + # @param reasoning [OpenAI::Models::Reasoning, nil] **o-series models only** + # + # @param service_tier [Symbol, OpenAI::Models::Responses::ResponseCreateParams::ServiceTier, nil] Specifies the latency tier to use for processing the request. This parameter is + # + # @param store [Boolean, nil] Whether to store the generated model response for later retrieval via + # + # @param temperature [Float, nil] What sampling temperature to use, between 0 and 2. Higher values like 0.8 will m + # + # @param text [OpenAI::Models::Responses::ResponseTextConfig] Configuration options for a text response from the model. Can be plain + # + # @param tool_choice [Symbol, OpenAI::Models::Responses::ToolChoiceOptions, OpenAI::Models::Responses::ToolChoiceTypes, OpenAI::Models::Responses::ToolChoiceFunction] How the model should select which tool (or tools) to use when generating + # + # @param tools [Array] An array of tools the model may call while generating a response. You + # + # @param top_p [Float, nil] An alternative to sampling with temperature, called nucleus sampling, + # + # @param truncation [Symbol, OpenAI::Models::Responses::ResponseCreateParams::Truncation, nil] The truncation strategy to use for the model response. + # + # @param user [String] A stable identifier for your end-users. + # + # @param request_options [OpenAI::RequestOptions, Hash{Symbol=>Object}, nil] + # + # @return [OpenAI::Helpers::Streaming::ResponseStream] + # + # @see OpenAI::Models::Responses::ResponseCreateParams + def stream(params) + parsed, options = OpenAI::Responses::ResponseCreateParams.dump_request(params) + starting_after, previous_response_id = parsed.values_at(:starting_after, :previous_response_id) + + if starting_after && !previous_response_id + raise ArgumentError, "starting_after can only be used with previous_response_id" + end + model, tool_models = get_structured_output_models(parsed) + + if previous_response_id + retrieve_params = {} + retrieve_params[:include] = params[:include] if params[:include] + retrieve_params[:request_options] = params[:request_options] if params[:request_options] + + raw_stream = retrieve_streaming(previous_response_id, retrieve_params) + else + unwrap = ->(raw) do + if raw[:type] == "response.completed" && raw[:response] + parse_structured_outputs!(raw[:response], model, tool_models) + end + raw + end + + parsed[:stream] = true + + raw_stream = @client.request( + method: :post, + path: "responses", + headers: {"accept" => "text/event-stream"}, + body: parsed, + stream: OpenAI::Internal::Stream, + model: OpenAI::Models::Responses::ResponseStreamEvent, + unwrap: unwrap, + options: options + ) + end + + OpenAI::Streaming::ResponseStream.new( + raw_stream: raw_stream, + text_format: model, + starting_after: starting_after + ) end # See {OpenAI::Resources::Responses#create} for non-streaming counterpart. @@ -207,7 +242,7 @@ def stream # # @param parallel_tool_calls [Boolean, nil] Whether to allow the model to run tool calls in parallel. # - # @param previous_response_id [String, nil] The unique ID of the previous response to the model. Use this to + # @param previous_response_id [String, nil] The unique ID of the previous response to the response to the model. Use this to resume streams from a given response. # # @param prompt [OpenAI::Models::Responses::ResponsePrompt, nil] Reference to a prompt template and its variables. # @@ -245,6 +280,7 @@ def stream_raw(params = {}) raise ArgumentError.new(message) end parsed.store(:stream, true) + @client.request( method: :post, path: "responses", @@ -378,6 +414,143 @@ def initialize(client:) @client = client @input_items = OpenAI::Resources::Responses::InputItems.new(client: client) end + + private + + # Post-processes raw API responses to parse and coerce structured outputs into typed Ruby objects. + # + # This method enhances the raw API response by parsing JSON content in structured outputs + # (both text outputs and function/tool calls) and converting them to their corresponding + # Ruby types using the JsonSchemaConverter models identified during request preparation. + # + # @param raw [Hash] The raw API response hash that will be mutated with parsed data + # @param model [JsonSchemaConverter|nil] The converter for structured text output, if specified + # @param tool_models [Hash] Hash mapping tool names to their converters + # @return [Hash] The mutated raw response with added :parsed fields containing typed Ruby objects + # + # The method performs two main transformations: + # 1. For structured text outputs: Finds output_text content, parses the JSON, and coerces it + # to the model type, adding the result as content[:parsed] + # 2. For function/tool calls: Looks up the tool's converter by name, parses the arguments JSON, + # and coerces it to the appropriate type, adding the result as output[:parsed] + def parse_structured_outputs!(raw, model, tool_models) + if model.is_a?(OpenAI::StructuredOutput::JsonSchemaConverter) + raw[:output] + &.flat_map do |output| + next [] unless output[:type] == "message" + output[:content].to_a + end + &.each do |content| + next unless content[:type] == "output_text" + begin + parsed = JSON.parse(content.fetch(:text), symbolize_names: true) + rescue JSON::ParserError => e + parsed = e + end + coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed) + content.store(:parsed, coerced) + end + end + raw[:output]&.each do |output| + next unless output[:type] == "function_call" + next if (model = tool_models[output.fetch(:name)]).nil? + begin + parsed = JSON.parse(output.fetch(:arguments), symbolize_names: true) + rescue JSON::ParserError => e + parsed = e + end + coerced = OpenAI::Internal::Type::Converter.coerce(model, parsed) + output.store(:parsed, coerced) + end + + raw + end + + # Extracts structured output models from request parameters and converts them to JSON Schema format. + # + # This method processes the parsed request parameters to identify any JsonSchemaConverter instances + # that define expected output schemas. It transforms these Ruby schema definitions into the JSON + # Schema format required by the OpenAI API, enabling type-safe structured outputs. + # + # @param parsed [Hash] The parsed request parameters that may contain structured output definitions + # @return [Array<(JsonSchemaConverter|nil, Hash)>] A tuple containing: + # - model: The JsonSchemaConverter for structured text output (or nil if not specified) + # - tool_models: Hash mapping tool names to their JsonSchemaConverter models + # + # The method handles multiple ways structured outputs can be specified: + # - Direct text format: { text: JsonSchemaConverter } + # - Nested text format: { text: { format: JsonSchemaConverter } } + # - Deep nested format: { text: { format: { type: :json_schema, schema: JsonSchemaConverter } } } + # - Tool parameters: { tools: [JsonSchemaConverter, ...] } or tools with parameters as converters + def get_structured_output_models(parsed) + model = nil + tool_models = {} + + case parsed + in {text: OpenAI::StructuredOutput::JsonSchemaConverter => model} + parsed.update( + text: { + format: { + type: :json_schema, + strict: true, + name: model.name.split("::").last, + schema: model.to_json_schema + } + } + ) + in {text: {format: OpenAI::StructuredOutput::JsonSchemaConverter => model}} + parsed.fetch(:text).update( + format: { + type: :json_schema, + strict: true, + name: model.name.split("::").last, + schema: model.to_json_schema + } + ) + in {text: {format: {type: :json_schema, + schema: OpenAI::StructuredOutput::JsonSchemaConverter => model}}} + parsed.dig(:text, :format).store(:schema, model.to_json_schema) + in {tools: Array => tools} + # rubocop:disable Metrics/BlockLength + mapped = tools.map do |tool| + case tool + in OpenAI::StructuredOutput::JsonSchemaConverter + name = tool.name.split("::").last + tool_models.store(name, tool) + { + type: :function, + strict: true, + name: name, + parameters: tool.to_json_schema + } + in {type: :function, parameters: OpenAI::StructuredOutput::JsonSchemaConverter => params} + func = tool.fetch(:function) + name = func[:name] ||= params.name.split("::").last + tool_models.store(name, params) + func.update(parameters: params.to_json_schema) + tool + in {type: _, function: {parameters: OpenAI::StructuredOutput::JsonSchemaConverter => params, **}} + name = tool[:function][:name] || params.name.split("::").last + tool_models.store(name, params) + tool[:function][:parameters] = params.to_json_schema + tool + in {type: _, function: Hash => func} if func[:parameters].is_a?(Class) && func[:parameters] < OpenAI::Internal::Type::BaseModel + params = func[:parameters] + name = func[:name] || params.name.split("::").last + tool_models.store(name, params) + func[:parameters] = params.to_json_schema + tool + else + tool + end + end + # rubocop:enable Metrics/BlockLength + tools.replace(mapped) + else + end + + [model, tool_models] + end end end end diff --git a/lib/openai/streaming.rb b/lib/openai/streaming.rb new file mode 100644 index 00000000..ab3fc935 --- /dev/null +++ b/lib/openai/streaming.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module OpenAI + Streaming = Helpers::Streaming +end diff --git a/lib/openai/version.rb b/lib/openai/version.rb index aa67665d..0167afa4 100644 --- a/lib/openai/version.rb +++ b/lib/openai/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module OpenAI - VERSION = "0.11.0" + VERSION = "0.12.0" end diff --git a/rbi/openai/helpers/streaming/events.rbi b/rbi/openai/helpers/streaming/events.rbi new file mode 100644 index 00000000..a084cb21 --- /dev/null +++ b/rbi/openai/helpers/streaming/events.rbi @@ -0,0 +1,31 @@ +# typed: strong + +module OpenAI + module Helpers + module Streaming + class ResponseTextDeltaEvent < OpenAI::Models::Responses::ResponseTextDeltaEvent + sig { returns(String) } + def snapshot + end + end + + class ResponseTextDoneEvent < OpenAI::Models::Responses::ResponseTextDoneEvent + sig { returns(T.untyped) } + def parsed + end + end + + class ResponseFunctionCallArgumentsDeltaEvent < OpenAI::Models::Responses::ResponseFunctionCallArgumentsDeltaEvent + sig { returns(String) } + def snapshot + end + end + + class ResponseCompletedEvent < OpenAI::Models::Responses::ResponseCompletedEvent + sig { returns(OpenAI::Models::Responses::Response) } + def response + end + end + end + end +end diff --git a/rbi/openai/helpers/streaming/response_stream.rbi b/rbi/openai/helpers/streaming/response_stream.rbi new file mode 100644 index 00000000..9cfa71c1 --- /dev/null +++ b/rbi/openai/helpers/streaming/response_stream.rbi @@ -0,0 +1,104 @@ +# typed: strong + +module OpenAI + module Helpers + module Streaming + class ResponseStream + include OpenAI::Internal::Type::BaseStream + + # Define the type union for streaming events that can be yielded + ResponseStreamEvent = + T.type_alias do + T.any( + OpenAI::Streaming::ResponseTextDeltaEvent, + OpenAI::Streaming::ResponseTextDoneEvent, + OpenAI::Streaming::ResponseCompletedEvent, + OpenAI::Streaming::ResponseFunctionCallArgumentsDeltaEvent, + # Pass through other raw events + OpenAI::Models::Responses::ResponseStreamEvent::Variants + ) + end + + Message = type_member { { fixed: ResponseStreamEvent } } + Elem = type_member { { fixed: ResponseStreamEvent } } + + sig do + params( + raw_stream: T.untyped, + text_format: T.untyped, + starting_after: T.nilable(Integer) + ).void + end + def initialize(raw_stream:, text_format:, starting_after:) + end + + sig { void } + def close + end + + sig { returns(T.self_type) } + def until_done + end + + sig { returns(OpenAI::Models::Responses::Response) } + def get_final_response + end + + sig { returns(String) } + def get_output_text + end + + sig { returns(T::Enumerator::Lazy[String]) } + def text + end + + # Override the each method to properly type the yielded events + sig do + params( + block: T.nilable(T.proc.params(event: ResponseStreamEvent).void) + ).returns(T.any(T.self_type, T::Enumerator[ResponseStreamEvent])) + end + def each(&block) + end + + private + + sig { returns(T.untyped) } + def iterator + end + end + + class ResponseStreamState + sig { returns(T.nilable(OpenAI::Models::Responses::Response)) } + attr_reader :completed_response + + sig { params(text_format: T.untyped).void } + def initialize(text_format:) + end + + sig { params(event: T.untyped).returns(T::Array[T.untyped]) } + def handle_event(event) + end + + sig do + params( + event: T.untyped, + current_snapshot: T.nilable(OpenAI::Models::Responses::Response) + ).returns(OpenAI::Models::Responses::Response) + end + def accumulate_event(event:, current_snapshot:) + end + + private + + sig { params(text: T.nilable(String)).returns(T.untyped) } + def parse_structured_text(text) + end + + sig { params(object: T.untyped, expected_type: Symbol).void } + def assert_type(object, expected_type) + end + end + end + end +end diff --git a/rbi/openai/internal/type/base_stream.rbi b/rbi/openai/internal/type/base_stream.rbi index 82b62c1a..e1155943 100644 --- a/rbi/openai/internal/type/base_stream.rbi +++ b/rbi/openai/internal/type/base_stream.rbi @@ -52,10 +52,17 @@ module OpenAI url: URI::Generic, status: Integer, response: Net::HTTPResponse, + unwrap: + T.any( + Symbol, + Integer, + T::Array[T.any(Symbol, Integer)], + T.proc.params(arg0: T.anything).returns(T.anything) + ), stream: T::Enumerable[Message] ).void end - def initialize(model:, url:, status:, response:, stream:) + def initialize(model:, url:, status:, response:, unwrap:, stream:) end # @api private diff --git a/rbi/openai/resources/responses.rbi b/rbi/openai/resources/responses.rbi index ce5086fc..5ef4a5a6 100644 --- a/rbi/openai/resources/responses.rbi +++ b/rbi/openai/resources/responses.rbi @@ -275,7 +275,13 @@ module OpenAI ), store: T.nilable(T::Boolean), temperature: T.nilable(Float), - text: OpenAI::Responses::ResponseTextConfig::OrHash, + text: + T.nilable( + T.any( + OpenAI::Responses::ResponseTextConfig::OrHash, + OpenAI::StructuredOutput::JsonSchemaConverter + ) + ), tool_choice: T.any( OpenAI::Responses::ToolChoiceOptions::OrSymbol, @@ -462,6 +468,125 @@ module OpenAI ) end + # See {OpenAI::Resources::Responses#create} for non-streaming counterpart. + # + # Creates a model response with a higher-level streaming interface that provides + # helper methods for processing events and aggregating stream outputs. + sig do + params( + input: + T.nilable(OpenAI::Responses::ResponseCreateParams::Input::Variants), + model: + T.nilable( + T.any( + String, + OpenAI::ChatModel::OrSymbol, + OpenAI::ResponsesModel::ResponsesOnlyModel::OrSymbol + ) + ), + background: T.nilable(T::Boolean), + include: + T.nilable( + T::Array[OpenAI::Responses::ResponseIncludable::OrSymbol] + ), + instructions: T.nilable(String), + max_output_tokens: T.nilable(Integer), + metadata: T.nilable(T::Hash[Symbol, String]), + parallel_tool_calls: T.nilable(T::Boolean), + previous_response_id: T.nilable(String), + prompt: T.nilable(OpenAI::Responses::ResponsePrompt::OrHash), + reasoning: T.nilable(OpenAI::Reasoning::OrHash), + service_tier: + T.nilable( + OpenAI::Responses::ResponseCreateParams::ServiceTier::OrSymbol + ), + store: T.nilable(T::Boolean), + temperature: T.nilable(Float), + text: + T.any( + OpenAI::Responses::ResponseTextConfig::OrHash, + OpenAI::StructuredOutput::JsonSchemaConverter + ), + tool_choice: + T.any( + OpenAI::Responses::ToolChoiceOptions::OrSymbol, + OpenAI::Responses::ToolChoiceTypes::OrHash, + OpenAI::Responses::ToolChoiceFunction::OrHash + ), + tools: + T.nilable( + T::Array[ + T.any( + OpenAI::Responses::FunctionTool::OrHash, + OpenAI::Responses::FileSearchTool::OrHash, + OpenAI::Responses::ComputerTool::OrHash, + OpenAI::Responses::Tool::Mcp::OrHash, + OpenAI::Responses::Tool::CodeInterpreter::OrHash, + OpenAI::Responses::Tool::ImageGeneration::OrHash, + OpenAI::Responses::Tool::LocalShell::OrHash, + OpenAI::Responses::WebSearchTool::OrHash, + OpenAI::StructuredOutput::JsonSchemaConverter + ) + ] + ), + top_p: T.nilable(Float), + truncation: + T.nilable( + OpenAI::Responses::ResponseCreateParams::Truncation::OrSymbol + ), + user: T.nilable(String), + starting_after: T.nilable(Integer), + request_options: T.nilable(OpenAI::RequestOptions::OrHash) + ).returns(OpenAI::Streaming::ResponseStream) + end + def stream( + # Text, image, or file inputs to the model, used to generate a response. + input: nil, + # Model ID used to generate the response, like `gpt-4o` or `o3`. + model: nil, + # Whether to run the model response in the background. + background: nil, + # Specify additional output data to include in the model response. + include: nil, + # A system (or developer) message inserted into the model's context. + instructions: nil, + # An upper bound for the number of tokens that can be generated for a response. + max_output_tokens: nil, + # Set of 16 key-value pairs that can be attached to an object. + metadata: nil, + # Whether to allow the model to run tool calls in parallel. + parallel_tool_calls: nil, + # The unique ID of the previous response to the model. Use this to create + # multi-turn conversations. + previous_response_id: nil, + # Reference to a prompt template and its variables. + prompt: nil, + # Configuration options for reasoning models. + reasoning: nil, + # Specifies the latency tier to use for processing the request. + service_tier: nil, + # Whether to store the generated model response for later retrieval via API. + store: nil, + # What sampling temperature to use, between 0 and 2. + temperature: nil, + # Configuration options for a text response from the model. + text: nil, + # How the model should select which tool (or tools) to use when generating a response. + tool_choice: nil, + # An array of tools the model may call while generating a response. + tools: nil, + # An alternative to sampling with temperature, called nucleus sampling. + top_p: nil, + # The truncation strategy to use for the model response. + truncation: nil, + # A stable identifier for your end-users. + user: nil, + # The sequence number of the event after which to start streaming (for resuming streams). + starting_after: nil, + request_options: {} + ) + end + # See {OpenAI::Resources::Responses#retrieve_streaming} for streaming counterpart. # # Retrieves a model response with the given ID. diff --git a/rbi/openai/streaming.rbi b/rbi/openai/streaming.rbi new file mode 100644 index 00000000..c448d83c --- /dev/null +++ b/rbi/openai/streaming.rbi @@ -0,0 +1,5 @@ +# typed: strong + +module OpenAI + Streaming = OpenAI::Helpers::Streaming +end diff --git a/sig/openai/internal/type/base_stream.rbs b/sig/openai/internal/type/base_stream.rbs index d43b91c2..75f49297 100644 --- a/sig/openai/internal/type/base_stream.rbs +++ b/sig/openai/internal/type/base_stream.rbs @@ -23,6 +23,10 @@ module OpenAI url: URI::Generic, status: Integer, response: top, + unwrap: Symbol + | Integer + | ::Array[Symbol | Integer] + | ^(top arg0) -> top, stream: Enumerable[Message] ) -> void diff --git a/test/openai/helpers/structured_output_test.rb b/test/openai/helpers/structured_output_test.rb index d0e40bdf..05ee3ce7 100644 --- a/test/openai/helpers/structured_output_test.rb +++ b/test/openai/helpers/structured_output_test.rb @@ -52,7 +52,8 @@ def test_coerce exactness, expect = rhs state = OpenAI::Internal::Type::Converter.new_coerce_state assert_pattern do - OpenAI::Internal::Type::Converter.coerce(target, input, state: state) => ^expect + coerced = OpenAI::Internal::Type::Converter.coerce(target, input, state: state) + coerced => ^expect state.fetch(:exactness).filter { _2.nonzero? }.to_h => ^exactness end end @@ -218,4 +219,26 @@ def test_definition_reusing end end end + + class M7 < OpenAI::Helpers::StructuredOutput::BaseModel + required :a, OpenAI::Helpers::StructuredOutput::ParsedJson + end + + def test_parsed_json + assert_pattern do + M7.new(a: {dog: "woof"}) => {a: {dog: "woof"}} + end + + err = JSON::ParserError.new("unexpected token at 'invalid json'") + + m1 = M7.new(a: err) + assert_raises(OpenAI::Errors::ConversionError) do + m1.a + end + + m2 = OpenAI::Internal::Type::Converter.coerce(M7, {a: err}) + assert_raises(OpenAI::Errors::ConversionError) do + m2.a + end + end end diff --git a/test/openai/resources/responses/streaming_test.rb b/test/openai/resources/responses/streaming_test.rb new file mode 100644 index 00000000..fd010b63 --- /dev/null +++ b/test/openai/resources/responses/streaming_test.rb @@ -0,0 +1,667 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class OpenAI::Test::Resources::Responses::StreamingTest < Minitest::Test + extend Minitest::Serial + include WebMock::API + + def before_all + super + WebMock.enable! + end + + def after_all + WebMock.disable! + super + end + + def setup + super + @client = OpenAI::Client.new(base_url: "http://localhost", api_key: "test-key") + end + + def teardown + WebMock.reset! + super + end + + def stub_streaming_response(response_body) + stub_request(:post, "http://localhost/responses") + .with( + body: hash_including( + instructions: "You are a helpful assistant", + messages: [{content: "Hello", role: "user"}], + model: "gpt-4", + stream: true + ) + ) + .to_return( + status: 200, + headers: {"Content-Type" => "text/event-stream"}, + body: response_body + ) + end + + def basic_params + { + instructions: "You are a helpful assistant", + messages: [{content: "Hello", role: :user}], + model: "gpt-4" + } + end + + def test_basic_text_streaming + stub_streaming_response(basic_text_sse_response) + + stream = @client.responses.stream(**basic_params) + events = stream.to_a + + assert_text_delta_events( + events, + expected_deltas: ["Hello there! ", "How can I help you ", "today?"], + expected_snapshot: "Hello there! How can I help you today?" + ) + + text_done = events.find { |e| e.type == :"response.output_text.done" } + assert_pattern do + text_done => OpenAI::Streaming::ResponseTextDoneEvent[ + text: "Hello there! How can I help you today?" + ] + end + + completed = events.find { |e| e.type == :"response.completed" } + assert_pattern do + completed => OpenAI::Streaming::ResponseCompletedEvent[ + response: { + id: "msg_001", + status: :completed + } + ] + end + end + + def test_get_final_response + stub_streaming_response(basic_text_sse_response) + + stream = @client.responses.stream(**basic_params) + response = stream.get_final_response + + assert_pattern do + response => OpenAI::Models::Responses::Response[ + id: "msg_001", + status: :completed, + output: [ + { + content: [{text: "Hello there! How can I help you today?"}] + } + ] + ] + end + end + + def test_get_output_text + stub_streaming_response(basic_text_sse_response) + + stream = @client.responses.stream(**basic_params) + text = stream.get_output_text + + assert_equal("Hello there! How can I help you today?", text) + end + + def test_get_output_text_with_multiple_parts + stub_streaming_response(multi_part_text_sse_response) + + stream = @client.responses.stream(**basic_params) + text = stream.get_output_text + + assert_equal("First part of text. Second part of text.", text) + end + + def test_get_output_text_with_no_text_content + stub_streaming_response(function_calling_sse_response) + + stream = @client.responses.stream(**function_tool_params) + text = stream.get_output_text + + assert_equal("", text) + end + + def test_early_stream_close + stub_streaming_response(basic_text_sse_response) + + events = [] + stream = @client.responses.stream(**basic_params) + stream.each do |event| + events << event + break if event.type == :"response.output_text.delta" && event.delta == "How can I help you " + end + + assert_equal(2, events.count { |e| e.type == :"response.output_text.delta" }) + refute(events.any? { |e| e.type == :"response.completed" }) + end + + class WeatherModel < OpenAI::BaseModel + required :location, String + required :temperature, Integer + end + + def test_structured_output_streaming + stub_streaming_response(structured_output_sse_response) + + text_deltas = [] + text_done = nil + + stream = @client.responses.stream(**basic_params, text: WeatherModel) + stream.each do |event| + text_deltas << event if event.type == :"response.output_text.delta" + text_done = event if event.type == :"response.output_text.done" + end + + assert_equal(3, text_deltas.length) + assert_equal( + [ + '{"location":"', + '{"location":"San Francisco","temperature":', + '{"location":"San Francisco","temperature":72}' + ], + text_deltas.map(&:snapshot) + ) + + assert_pattern do + text_done => OpenAI::Streaming::ResponseTextDoneEvent[ + parsed: WeatherModel[ + location: "San Francisco", + temperature: 72 + ] + ] + end + end + + def test_structured_output_streaming_with_malformed_json + stub_streaming_response(malformed_json_sse_response) + + error = assert_raises(RuntimeError) do + stream = @client.responses.stream(**basic_params, text: WeatherModel) + stream.each { |_event| } # Consume the stream to trigger the error. + end + + assert_match(/Failed to parse structured text as JSON/, error.message) + assert_match(/Raw text:/, error.message) + end + + def test_function_calling_streaming + stub_streaming_response(function_calling_sse_response) + + stream = @client.responses.stream(**function_tool_params) + events = stream.to_a + + assert_function_delta_events( + events, + expected_deltas: ['{"location":"', "San Francisco", '"}'], + expected_snapshot: '{"location":"San Francisco"}' + ) + end + + def test_incomplete_streaming + stub_streaming_response(incomplete_sse_response) + + stream = @client.responses.stream(**basic_params) + assert_raises(RuntimeError, "Didn't receive a 'response.completed' event") do + stream.get_final_response + end + end + + def test_text_method + stub_streaming_response(basic_text_sse_response) + + stream = @client.responses.stream(**basic_params) + text_chunks = stream.text.map do |chunk| + chunk + end + + assert_equal(["Hello there! ", "How can I help you ", "today?"], text_chunks) + end + + def test_text_method_with_structured_output + stub_streaming_response(structured_output_sse_response) + + stream = @client.responses.stream(**basic_params, text: WeatherModel) + text_chunks = stream.text.map do |chunk| + chunk + end + + assert_equal(['{"location":"', 'San Francisco","temperature":', "72}"], text_chunks) + end + + def test_resume_stream_with_response_id + # Stub the GET request to retrieve the response. + stub_request(:get, "http://localhost/responses/msg_123?stream=true") + .to_return( + status: 200, + headers: {"Content-Type" => "text/event-stream"}, + body: resume_stream_sse_response + ) + + stream = @client.responses.stream(previous_response_id: "msg_123") + events = stream.to_a + + text_done = events.find { |e| e.type == :"response.output_text.done" } + assert_equal("Hello there! How can I help you today?", text_done.text) + + completed = events.find { |e| e.type == :"response.completed" } + assert_pattern do + completed => OpenAI::Streaming::ResponseCompletedEvent[ + response: { + id: "msg_123", + status: :completed + } + ] + end + end + + def test_starting_after_without_response_id_errors + assert_raises(ArgumentError) do + @client.responses.stream(**basic_params, starting_after: 5) + end + end + + def test_resume_stream_with_response_id_and_starting_after + # Stub the GET request to retrieve the response. + stub_request(:get, "http://localhost/responses/msg_456?stream=true") + .to_return( + status: 200, + headers: {"Content-Type" => "text/event-stream"}, + body: resume_stream_with_starting_after_sse_response + ) + + stream = @client.responses.stream(previous_response_id: "msg_456", starting_after: 7) + events = stream.to_a + + # Should only get events after sequence 7. + assert(events.all? { |e| e.sequence_number > 7 }) + assert_equal(4, events.length) + + text_delta = events.find { |e| e.type == :"response.output_text.delta" } + assert_equal("today?", text_delta.delta) + assert_equal(8, text_delta.sequence_number) + + # Verify with assert_pattern that we get the correct event class and properties. + assert_pattern do + text_delta => OpenAI::Streaming::ResponseTextDeltaEvent[ + type: :"response.output_text.delta", + delta: "today?", + sequence_number: 8 + ] + end + + text_done = events.find { |e| e.type == :"response.output_text.done" } + assert_equal("Hello there! How can I help you today?", text_done.text) + assert_equal(9, text_done.sequence_number) + + completed = events.find { |e| e.type == :"response.completed" } + assert_equal("msg_456", completed.response[:id]) + assert_equal(11, completed.sequence_number) + end + + def test_resume_stream_with_structured_output + # Stub the GET request to retrieve the response (streaming). + stub_request(:get, "http://localhost/responses/msg_structured?stream=true") + .to_return( + status: 200, + headers: {"Content-Type" => "text/event-stream"}, + body: resume_stream_structured_output_sse_response + ) + + stream = @client.responses.stream(previous_response_id: "msg_structured", text: WeatherModel) + events = stream.to_a + + text_done = events.find { |e| e.type == :"response.output_text.done" } + assert_equal("{\"location\":\"San Francisco\",\"temperature\":72}", text_done.text) + + # Verify the parsed content is available. + assert_pattern do + text_done => OpenAI::Streaming::ResponseTextDoneEvent[ + parsed: WeatherModel[ + location: "San Francisco", + temperature: 72 + ] + ] + end + + completed = events.find { |e| e.type == :"response.completed" } + assert_pattern do + completed => OpenAI::Streaming::ResponseCompletedEvent[ + response: { + id: "msg_structured", + status: :completed + } + ] + end + end + + def test_structured_output_parsed_in_final_response + stub_streaming_response(structured_output_sse_response) + + stream = @client.responses.stream(**basic_params, text: WeatherModel) + final_response = stream.get_final_response + + assert_pattern do + final_response => OpenAI::Models::Responses::Response[ + output: [ + { + content: [ + { + text: "{\"location\":\"San Francisco\",\"temperature\":72}", + parsed: WeatherModel[ + location: "San Francisco", + temperature: 72 + ] + } + ] + } + ] + ] + end + end + + private + + def function_tool_params + basic_params.merge( + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather for a location", + strict: true, + parameters: WeatherModel + } + } + ] + ) + end + + def assert_text_delta_events(events, expected_deltas:, expected_snapshot:) + text_deltas = events.select { |e| e.is_a?(OpenAI::Streaming::ResponseTextDeltaEvent) } + + assert_equal(expected_deltas.length, text_deltas.length, "Incorrect number of text delta events") + assert_equal(expected_deltas, text_deltas.map(&:delta), "Incorrect delta values") + assert_equal(expected_snapshot, text_deltas.last.snapshot, "Incorrect final snapshot") + end + + def assert_function_delta_events(events, expected_deltas:, expected_snapshot:) + function_deltas = events.select do |e| + e.is_a?(OpenAI::Streaming::ResponseFunctionCallArgumentsDeltaEvent) + end + + assert_equal(expected_deltas.length, function_deltas.length, "Incorrect number of function delta events") + assert_equal(expected_deltas, function_deltas.map(&:delta), "Incorrect delta values") + assert_equal(expected_snapshot, function_deltas.last.snapshot, "Incorrect final snapshot") + end + + def basic_text_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_001","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":2,"response":{"id":"msg_001","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":3,"response_id":"msg_001","output_index":0,"item":{"id":"item_001","object":"realtime.item","type":"message","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":4,"response_id":"msg_001","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":5,"response_id":"msg_001","item_id":"item_001","output_index":0,"content_index":0,"delta":"Hello there! "} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":6,"response_id":"msg_001","item_id":"item_001","output_index":0,"content_index":0,"delta":"How can I help you "} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":7,"response_id":"msg_001","item_id":"item_001","output_index":0,"content_index":0,"delta":"today?"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":8,"response_id":"msg_001","item_id":"item_001","output_index":0,"content_index":0,"text":"Hello there! How can I help you today?"} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":9,"response_id":"msg_001","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello there! How can I help you today?"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":10,"response_id":"msg_001","item_id":"item_001","output_index":0,"item":{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello there! How can I help you today?"}]}} + + event: response.completed + data: {"type":"response.completed","sequence_number":11,"response":{"id":"msg_001","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello there! How can I help you today?"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end + + def resume_stream_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_123","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello there! How can I help you today?"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"response_id":"msg_123","output_index":0,"item":{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello there! How can I help you today?"}]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":3,"response_id":"msg_123","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello there! How can I help you today?"}} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":4,"response_id":"msg_123","item_id":"item_001","output_index":0,"content_index":0,"text":"Hello there! How can I help you today?"} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":5,"response_id":"msg_123","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello there! How can I help you today?"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":6,"response_id":"msg_123","item_id":"item_001","output_index":0,"item":{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello there! How can I help you today?"}]}} + + event: response.completed + data: {"type":"response.completed","sequence_number":7,"response":{"id":"msg_123","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello there! How can I help you today?"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end + + def resume_stream_with_starting_after_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_456","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"response_id":"msg_456","output_index":0,"item":{"id":"item_001","object":"realtime.item","type":"message","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":3,"response_id":"msg_456","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":4,"response_id":"msg_456","item_id":"item_001","output_index":0,"content_index":0,"delta":"Hello there! "} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":5,"response_id":"msg_456","item_id":"item_001","output_index":0,"content_index":0,"delta":"How can I help you "} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":6,"response_id":"msg_456","item_id":"item_001","output_index":0,"content_index":0,"delta":"today?"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":8,"response_id":"msg_456","item_id":"item_001","output_index":0,"content_index":0,"delta":"today?"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":9,"response_id":"msg_456","item_id":"item_001","output_index":0,"content_index":0,"text":"Hello there! How can I help you today?"} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":10,"response_id":"msg_456","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello there! How can I help you today?"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":11,"response":{"id":"msg_456","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello there! How can I help you today?"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end + + def structured_output_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_002","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"response_id":"msg_002","output_index":0,"item":{"id":"item_002","object":"realtime.item","type":"message","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":3,"response_id":"msg_002","item_id":"item_002","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":4,"response_id":"msg_002","item_id":"item_002","output_index":0,"content_index":0,"delta":"{\\"location\\":\\""} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":5,"response_id":"msg_002","item_id":"item_002","output_index":0,"content_index":0,"delta":"San Francisco\\",\\"temperature\\":"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":6,"response_id":"msg_002","item_id":"item_002","output_index":0,"content_index":0,"delta":"72}"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":7,"response_id":"msg_002","item_id":"item_002","output_index":0,"content_index":0,"text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":8,"response_id":"msg_002","item_id":"item_002","output_index":0,"item":{"id":"item_002","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}]}} + + event: response.completed + data: {"type":"response.completed","sequence_number":9,"response":{"id":"msg_002","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_002","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end + + def function_calling_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_003","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"response_id":"msg_003","output_index":0,"item":{"id":"item_003","object":"realtime.item","type":"function_call","status":"in_progress","name":"get_weather","arguments":"","call_id":"call_001"}} + + event: response.function_call_arguments.delta + data: {"type":"response.function_call_arguments.delta","sequence_number":3,"item_id":"item_003","output_index":0,"delta":"{\\"location\\":\\""} + + event: response.function_call_arguments.delta + data: {"type":"response.function_call_arguments.delta","sequence_number":4,"item_id":"item_003","output_index":0,"delta":"San Francisco"} + + event: response.function_call_arguments.delta + data: {"type":"response.function_call_arguments.delta","sequence_number":5,"item_id":"item_003","output_index":0,"delta":"\\"}"} + + event: response.function_call_arguments.done + data: {"type":"response.function_call_arguments.done","sequence_number":6,"item_id":"item_003","output_index":0,"arguments":"{\\"location\\":\\"San Francisco\\"}"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":7,"response_id":"msg_003","item_id":"item_003","output_index":0,"item":{"id":"item_003","object":"realtime.item","type":"function_call","status":"completed","name":"get_weather","arguments":"{\\"location\\":\\"San Francisco\\"}","call_id":"call_001"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":8,"response":{"id":"msg_003","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_003","object":"realtime.item","type":"function_call","status":"completed","name":"get_weather","arguments":"{\\"location\\":\\"San Francisco\\"}","call_id":"call_001"}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end + + def incomplete_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_005","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","response_id":"msg_005","item_id":"item_005","output_index":0,"content_index":0,"delta":"Hello"} + + SSE + end + + def malformed_json_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_malformed","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"response_id":"msg_malformed","output_index":0,"item":{"id":"item_malformed","object":"realtime.item","type":"message","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":3,"response_id":"msg_malformed","item_id":"item_malformed","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":4,"response_id":"msg_malformed","item_id":"item_malformed","output_index":0,"content_index":0,"delta":"{\\"location\\":\\""} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":5,"response_id":"msg_malformed","item_id":"item_malformed","output_index":0,"content_index":0,"delta":"San Francisco\\", malformed JSON"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":6,"response_id":"msg_malformed","item_id":"item_malformed","output_index":0,"content_index":0,"text":"{\\"location\\":\\"San Francisco\\", malformed JSON"} + + event: response.completed + data: {"type":"response.completed","sequence_number":7,"response":{"id":"msg_malformed","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_malformed","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\", malformed JSON"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end + + def multi_part_text_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_multi","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"response_id":"msg_multi","output_index":0,"item":{"id":"item_001","object":"realtime.item","type":"message","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":3,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":4,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":0,"delta":"First part of text."} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":5,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":0,"text":"First part of text."} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":6,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":0,"part":{"type":"output_text","text":"First part of text."}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":7,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":1,"part":{"type":"output_text","text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":8,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":1,"delta":" Second part of text."} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":9,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":1,"text":" Second part of text."} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":10,"response_id":"msg_multi","item_id":"item_001","output_index":0,"content_index":1,"part":{"type":"output_text","text":" Second part of text."}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":11,"response_id":"msg_multi","item_id":"item_001","output_index":0,"item":{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"First part of text."},{"type":"output_text","text":" Second part of text."}]}} + + event: response.completed + data: {"type":"response.completed","sequence_number":12,"response":{"id":"msg_multi","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_001","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"First part of text."},{"type":"output_text","text":" Second part of text."}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end + + def resume_stream_structured_output_sse_response + <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_structured","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_002","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"response_id":"msg_structured","output_index":0,"item":{"id":"item_002","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":3,"response_id":"msg_structured","item_id":"item_002","output_index":0,"content_index":0,"part":{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":4,"response_id":"msg_structured","item_id":"item_002","output_index":0,"content_index":0,"text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":5,"response_id":"msg_structured","item_id":"item_002","output_index":0,"content_index":0,"part":{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":6,"response_id":"msg_structured","item_id":"item_002","output_index":0,"item":{"id":"item_002","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}]}} + + event: response.completed + data: {"type":"response.completed","sequence_number":7,"response":{"id":"msg_structured","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"item_002","object":"realtime.item","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"{\\"location\\":\\"San Francisco\\",\\"temperature\\":72}"}]}],"usage":{"total_tokens":20,"input_tokens":10,"output_tokens":10},"metadata":null}} + + SSE + end +end