diff --git a/.release-please-manifest.json b/.release-please-manifest.json index aff3ead3..1f0d8a9b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0-beta.2" + ".": "0.4.0-beta.1" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2614f4ca..57774fe0 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-fc64d7c2c8f51f750813375356c3f3fdfc7fc1b1b34f19c20a5410279d445d37.yml openapi_spec_hash: 618285fc70199ee32b9ebe4bf72f7e4c -config_hash: c497f6b750cc89c0bf2eefc0bc839c70 +config_hash: 535b6e5f26a295d609b259c8cb8f656c diff --git a/CHANGELOG.md b/CHANGELOG.md index 05abf4ff..ed1e1b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.4.0-beta.1 (2025-05-23) + +Full Changelog: [v0.1.0-beta.2...v0.4.0-beta.1](https://github.com/openai/openai-ruby/compare/v0.1.0-beta.2...v0.4.0-beta.1) + +### Features + +* structured output for responses API (text) ([#688](https://github.com/openai/openai-ruby/issues/688)) ([282ec24](https://github.com/openai/openai-ruby/commit/282ec24c89511c1cd50029fe154e10d772e23239)) +* structured output for responses API (tools) ([#691](https://github.com/openai/openai-ruby/issues/691)) ([5e524ea](https://github.com/openai/openai-ruby/commit/5e524ea48020125911204c8050b49e360d7513d7)) + + +### Chores + +* **internal:** fix release workflows ([e1b31a6](https://github.com/openai/openai-ruby/commit/e1b31a6d6c3064f57e82aa1c3f48f2c797619b5a)) +* **internal:** version bump ([b2dd8dd](https://github.com/openai/openai-ruby/commit/b2dd8dd1aac3ff9acf69953a0d04c74721b47e36)) +* update README for public release ([#145](https://github.com/openai/openai-ruby/issues/145)) ([64e3849](https://github.com/openai/openai-ruby/commit/64e384933c2f80508002dfacf89efe74510c1330)) + ## 0.1.0-beta.2 (2025-05-22) Full Changelog: [v0.1.0-beta.1...v0.1.0-beta.2](https://github.com/openai/openai-ruby/compare/v0.1.0-beta.1...v0.1.0-beta.2) diff --git a/Gemfile.lock b/Gemfile.lock index 30d820b9..9496ce68 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,7 +11,7 @@ GIT PATH remote: . specs: - openai (0.1.0.pre.beta.2) + openai (0.4.0.pre.beta.1) connection_pool GEM diff --git a/README.md b/README.md index e2e3ace6..84f09d30 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,12 @@ The REST API documentation can be found on [platform.openai.com](https://platfor ## Installation -ℹ️ The `openai` gem is not yet available on [rubygems.org](https://rubygems.org). - To use this gem, install via Bundler by adding the following to your application's `Gemfile`: ```ruby -gem "openai", github: "openai/openai-ruby", branch: "main" +gem "openai", "~> 0.4.0.pre.beta.1" ``` diff --git a/bin/check-release-environment b/bin/check-release-environment index 6303e291..6aa95c4f 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -2,6 +2,10 @@ errors=() +if [ -z "${STAINLESS_API_KEY}" ]; then + errors+=("The STAINLESS_API_KEY secret has not been set. Please contact Stainless for an API key & set it in your organization secrets on GitHub.") +fi + if [ -z "${GEM_HOST_API_KEY}" ]; then errors+=("The OPENAI_GEM_HOST_API_KEY secret has not been set. Please set it in either this repository's secrets or your organization secrets") fi diff --git a/examples/structured_outputs_chat_completions.rb b/examples/structured_outputs_chat_completions.rb index f177a208..3debd078 100755 --- a/examples/structured_outputs_chat_completions.rb +++ b/examples/structured_outputs_chat_completions.rb @@ -49,7 +49,7 @@ class CalendarEvent < OpenAI::BaseModel chat_completion .choices - .filter { !_1.message.refusal } + .reject { _1.message.refusal } .each do |choice| pp(choice.message.parsed) end diff --git a/examples/structured_outputs_chat_completions_function_calling.rb b/examples/structured_outputs_chat_completions_function_calling.rb index 5a473089..847781a3 100755 --- a/examples/structured_outputs_chat_completions_function_calling.rb +++ b/examples/structured_outputs_chat_completions_function_calling.rb @@ -24,7 +24,7 @@ class GetWeather < OpenAI::BaseModel chat_completion .choices - .filter { !_1.message.refusal } + .reject { _1.message.refusal } .flat_map { _1.message.tool_calls.to_a } .each do |tool_call| pp(tool_call.function.parsed) diff --git a/examples/structured_outputs_responses.rb b/examples/structured_outputs_responses.rb new file mode 100755 index 00000000..9fedcfd7 --- /dev/null +++ b/examples/structured_outputs_responses.rb @@ -0,0 +1,55 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/openai" + +class Location < OpenAI::BaseModel + required :address, String + required :city, String, doc: "City name" + required :postal_code, String, nil?: true +end + +# Participant model with an optional last_name and an enum for status +class Participant < OpenAI::BaseModel + required :first_name, String + required :last_name, String, nil?: true + required :status, OpenAI::EnumOf[:confirmed, :unconfirmed, :tentative] +end + +# CalendarEvent model with a list of participants. +class CalendarEvent < OpenAI::BaseModel + required :name, String + required :date, String + required :participants, OpenAI::ArrayOf[Participant] + required :is_virtual, OpenAI::Boolean + required :location, + OpenAI::UnionOf[String, Location], + nil?: true, + doc: "Event location" +end + +client = OpenAI::Client.new + +response = client.responses.create( + model: "gpt-4o-2024-08-06", + input: [ + {role: :system, content: "Extract the event information."}, + { + role: :user, + content: <<~CONTENT + Alice Shah and Lena are going to a science fair on Friday at 123 Main St. in San Diego. + They have also invited Jasper Vellani and Talia Groves - Jasper has not responded and Talia said she is thinking about it. + CONTENT + } + ], + text: CalendarEvent +) + +response + .output + .flat_map { _1.content } + # filter out refusal responses + .grep_v(OpenAI::Models::Responses::ResponseOutputRefusal) + .each do |content| + pp(content.parsed) + end diff --git a/examples/structured_outputs_responses_function_calling.rb b/examples/structured_outputs_responses_function_calling.rb new file mode 100755 index 00000000..78b79ce7 --- /dev/null +++ b/examples/structured_outputs_responses_function_calling.rb @@ -0,0 +1,28 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/openai" + +class GetWeather < OpenAI::BaseModel + required :location, String, doc: "City and country e.g. Bogotá, Colombia" +end + +# gets API Key from environment variable `OPENAI_API_KEY` +client = OpenAI::Client.new + +response = client.responses.create( + model: "gpt-4o-2024-08-06", + input: [ + { + role: :user, + content: "What's the weather like in Paris today?" + } + ], + tools: [GetWeather] +) + +response + .output + .each do |output| + pp(output.parsed) + end diff --git a/lib/openai/models/chat/completion_create_params.rb b/lib/openai/models/chat/completion_create_params.rb index 458bda0c..77791681 100644 --- a/lib/openai/models/chat/completion_create_params.rb +++ b/lib/openai/models/chat/completion_create_params.rb @@ -530,7 +530,7 @@ module ResponseFormat # Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs). variant -> { OpenAI::ResponseFormatJSONSchema } - # An {OpenAI::BaseModel} can be provided and implicitly converted into {OpenAI::ResponseFormatJSONSchema}. + # An {OpenAI::BaseModel} can be provided and implicitly converted into {OpenAI::Models::ResponseFormatJSONSchema}. # See examples for more details. # # Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs). diff --git a/lib/openai/models/response_format_json_schema.rb b/lib/openai/models/response_format_json_schema.rb index e84b2194..e050163f 100644 --- a/lib/openai/models/response_format_json_schema.rb +++ b/lib/openai/models/response_format_json_schema.rb @@ -50,7 +50,7 @@ class JSONSchema < OpenAI::Internal::Type::BaseModel # @return [Hash{Symbol=>Object}, nil] optional :schema, union: -> { - OpenAI::StructuredOutput::UnionOf[ + OpenAI::UnionOf[ OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown], OpenAI::StructuredOutput::JsonSchemaConverter ] diff --git a/lib/openai/models/responses/function_tool.rb b/lib/openai/models/responses/function_tool.rb index f4db7602..08d88c8a 100644 --- a/lib/openai/models/responses/function_tool.rb +++ b/lib/openai/models/responses/function_tool.rb @@ -14,7 +14,12 @@ class FunctionTool < OpenAI::Internal::Type::BaseModel # A JSON schema object describing the parameters of the function. # # @return [Hash{Symbol=>Object}, nil] - required :parameters, OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown], nil?: true + required :parameters, + union: OpenAI::UnionOf[ + OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown], + OpenAI::StructuredOutput::JsonSchemaConverter + ], + nil?: true # @!attribute strict # Whether to enforce strict parameter validation. Default `true`. diff --git a/lib/openai/models/responses/response_create_params.rb b/lib/openai/models/responses/response_create_params.rb index 2058a351..4f70650a 100644 --- a/lib/openai/models/responses/response_create_params.rb +++ b/lib/openai/models/responses/response_create_params.rb @@ -159,7 +159,13 @@ class ResponseCreateParams < OpenAI::Internal::Type::BaseModel # - [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs) # # @return [OpenAI::Models::Responses::ResponseTextConfig, nil] - optional :text, -> { OpenAI::Responses::ResponseTextConfig } + optional :text, + union: -> { + OpenAI::UnionOf[ + OpenAI::Responses::ResponseTextConfig, + OpenAI::StructuredOutput::JsonSchemaConverter + ] + } # @!attribute tool_choice # How the model should select which tool (or tools) to use when generating a @@ -185,7 +191,7 @@ class ResponseCreateParams < OpenAI::Internal::Type::BaseModel # the model to call your own code. Learn more about # [function calling](https://platform.openai.com/docs/guides/function-calling). # - # @return [Array, nil] + # @return [Array, nil] optional :tools, -> { OpenAI::Internal::Type::ArrayOf[union: OpenAI::Responses::Tool] } # @!attribute top_p @@ -252,7 +258,7 @@ class ResponseCreateParams < OpenAI::Internal::Type::BaseModel # # @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 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, # diff --git a/lib/openai/models/responses/response_format_text_config.rb b/lib/openai/models/responses/response_format_text_config.rb index 43f6ad0c..92c1a5de 100644 --- a/lib/openai/models/responses/response_format_text_config.rb +++ b/lib/openai/models/responses/response_format_text_config.rb @@ -28,6 +28,12 @@ module ResponseFormatTextConfig # Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs). variant :json_schema, -> { OpenAI::Responses::ResponseFormatTextJSONSchemaConfig } + # An {OpenAI::BaseModel} can be provided and implicitly converted into {OpenAI::Models::Responses::ResponseFormatTextJSONSchemaConfig}. + # See examples for more details. + # + # Learn more about [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs). + variant -> { OpenAI::StructuredOutput::JsonSchemaConverter } + # JSON object response format. An older method of generating JSON responses. # Using `json_schema` is recommended for models that support it. Note that the # model will not generate JSON without a system or user message instructing it diff --git a/lib/openai/models/responses/response_format_text_json_schema_config.rb b/lib/openai/models/responses/response_format_text_json_schema_config.rb index 06e57803..44071d59 100644 --- a/lib/openai/models/responses/response_format_text_json_schema_config.rb +++ b/lib/openai/models/responses/response_format_text_json_schema_config.rb @@ -15,8 +15,13 @@ class ResponseFormatTextJSONSchemaConfig < OpenAI::Internal::Type::BaseModel # The schema for the response format, described as a JSON Schema object. Learn how # to build JSON schemas [here](https://json-schema.org/). # - # @return [Hash{Symbol=>Object}] - required :schema, OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown] + # @return [Hash{Symbol=>Object}, OpenAI::StructuredOutput::JsonSchemaConverter] + required :schema, + union: -> { + OpenAI::UnionOf[ + OpenAI::Internal::Type::HashOf[OpenAI::Internal::Type::Unknown], OpenAI::StructuredOutput::JsonSchemaConverter + ] + } # @!attribute type # The type of response format being defined. Always `json_schema`. @@ -52,7 +57,7 @@ class ResponseFormatTextJSONSchemaConfig < OpenAI::Internal::Type::BaseModel # # @param name [String] The name of the response format. Must be a-z, A-Z, 0-9, or contain # - # @param schema [Hash{Symbol=>Object}] The schema for the response format, described as a JSON Schema object. + # @param schema [Hash{Symbol=>Object}, OpenAI::StructuredOutput::JsonSchemaConverter] The schema for the response format, described as a JSON Schema object. # # @param description [String] A description of what the response format is for, used by the model to # diff --git a/lib/openai/models/responses/response_function_tool_call.rb b/lib/openai/models/responses/response_function_tool_call.rb index 55602423..fd7afc91 100644 --- a/lib/openai/models/responses/response_function_tool_call.rb +++ b/lib/openai/models/responses/response_function_tool_call.rb @@ -10,6 +10,12 @@ class ResponseFunctionToolCall < OpenAI::Internal::Type::BaseModel # @return [String] required :arguments, String + # @!attribute parsed + # The parsed contents of the arguments. + # + # @return [Object, nil] + required :parsed, OpenAI::Internal::Type::Unknown + # @!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 a9646f34..7d4005e3 100644 --- a/lib/openai/models/responses/response_output_text.rb +++ b/lib/openai/models/responses/response_output_text.rb @@ -19,6 +19,12 @@ class ResponseOutputText < OpenAI::Internal::Type::BaseModel # @return [String] required :text, String + # @!attribute parsed + # The parsed contents of the output, if JSON schema is specified. + # + # @return [Object, nil] + optional :parsed, OpenAI::Internal::Type::Unknown + # @!attribute type # The type of the output text. Always `output_text`. # diff --git a/lib/openai/models/responses/tool.rb b/lib/openai/models/responses/tool.rb index f97fae7e..68b892a7 100644 --- a/lib/openai/models/responses/tool.rb +++ b/lib/openai/models/responses/tool.rb @@ -12,6 +12,8 @@ module Tool # Defines a function in your own code the model can choose to call. Learn more about [function calling](https://platform.openai.com/docs/guides/function-calling). variant :function, -> { OpenAI::Responses::FunctionTool } + variant -> { OpenAI::StructuredOutput::JsonSchemaConverter } + # A tool that searches for relevant content from uploaded files. Learn more about the [file search tool](https://platform.openai.com/docs/guides/tools-file-search). variant :file_search, -> { OpenAI::Responses::FileSearchTool } diff --git a/lib/openai/resources/responses.rb b/lib/openai/resources/responses.rb index 0b23f4e0..84ab1476 100644 --- a/lib/openai/resources/responses.rb +++ b/lib/openai/resources/responses.rb @@ -74,10 +74,85 @@ def create(params) message = "Please use `#stream_raw` for the streaming use case." 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) + else + end + end + tools.replace(mapped) + else + end + + 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 + end @client.request( method: :post, path: "responses", body: parsed, + unwrap: unwrap, model: OpenAI::Responses::Response, options: options ) diff --git a/lib/openai/version.rb b/lib/openai/version.rb index 5dc9f2b6..03fe9c50 100644 --- a/lib/openai/version.rb +++ b/lib/openai/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module OpenAI - VERSION = "0.1.0.pre.beta.2" + VERSION = "0.4.0.pre.beta.1" end diff --git a/rbi/openai/models/responses/response_create_params.rbi b/rbi/openai/models/responses/response_create_params.rbi index 592d684d..671856d6 100644 --- a/rbi/openai/models/responses/response_create_params.rbi +++ b/rbi/openai/models/responses/response_create_params.rbi @@ -159,7 +159,15 @@ module OpenAI sig { returns(T.nilable(OpenAI::Responses::ResponseTextConfig)) } attr_reader :text - sig { params(text: OpenAI::Responses::ResponseTextConfig::OrHash).void } + sig do + params( + text: + T.any( + OpenAI::Responses::ResponseTextConfig::OrHash, + OpenAI::StructuredOutput::JsonSchemaConverter + ) + ).void + end attr_writer :text # How the model should select which tool (or tools) to use when generating a diff --git a/rbi/openai/models/responses/response_function_tool_call.rbi b/rbi/openai/models/responses/response_function_tool_call.rbi index 5e98ba1a..be14e555 100644 --- a/rbi/openai/models/responses/response_function_tool_call.rbi +++ b/rbi/openai/models/responses/response_function_tool_call.rbi @@ -16,6 +16,10 @@ module OpenAI sig { returns(String) } attr_accessor :arguments + # The parsed contents of the arguments. + sig { returns(T.anything) } + attr_accessor :parsed + # The unique ID of the function tool call generated by the model. sig { returns(String) } attr_accessor :call_id diff --git a/rbi/openai/models/responses/response_output_text.rbi b/rbi/openai/models/responses/response_output_text.rbi index f73a5755..fc5841dc 100644 --- a/rbi/openai/models/responses/response_output_text.rbi +++ b/rbi/openai/models/responses/response_output_text.rbi @@ -30,6 +30,10 @@ module OpenAI sig { returns(String) } attr_accessor :text + # The parsed contents of the output, if JSON schema is specified. + sig { returns(T.anything) } + attr_accessor :parsed + # The type of the output text. Always `output_text`. sig { returns(Symbol) } attr_accessor :type diff --git a/rbi/openai/resources/responses.rbi b/rbi/openai/resources/responses.rbi index 3753b593..ecb2f74c 100644 --- a/rbi/openai/resources/responses.rbi +++ b/rbi/openai/resources/responses.rbi @@ -45,7 +45,11 @@ module OpenAI ), store: T.nilable(T::Boolean), temperature: T.nilable(Float), - text: OpenAI::Responses::ResponseTextConfig::OrHash, + text: + T.any( + OpenAI::Responses::ResponseTextConfig::OrHash, + OpenAI::StructuredOutput::JsonSchemaConverter + ), tool_choice: T.any( OpenAI::Responses::ToolChoiceOptions::OrSymbol,