diff --git a/.castiron.stats.yml b/.castiron.stats.yml index c0085028..3fc92c18 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,6 +1,6 @@ schema_version: 1 -generation_id: c5512019-402c-4498-b28c-55fb7a36dc0e -openapi_spec_hash: 1faf0319c407c6534ef7c381614afbb6 -openapi_transformed_spec_hash: 53eff50caa9d18046e4ff0615bc7512d +generation_id: f5bdc52b-495d-4bb5-b835-a2925f574dbc +openapi_spec_hash: 971c659b020aa37865787aa082cc4159 +openapi_transformed_spec_hash: 75836bac7e1a2e2632c2709238aa355c config_hash: 4617b0962f16d328804312ac81aac630 -codegen_sha: dd4e5fa5e392402820c8ddebf0a66fedca7a4f5e +codegen_sha: 53a522327b03f11f80afbc2a00ae4c73ec5ccad4 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 5c020044..e22fa898 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -7,45 +7,10 @@ on: description: Runner label for CI jobs required: true type: string - publish-stainless-artifact: - description: Build and upload an installable Stainless artifact - required: true - type: boolean permissions: {} jobs: - stainless-artifact: - timeout-minutes: 10 - name: stainless artifact - if: inputs.publish-stainless-artifact - permissions: - contents: read - id-token: write # Authenticate the artifact upload to pkg.stainless.com. - runs-on: ${{ inputs.runner }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - name: Set up Ruby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 - with: - bundler-cache: true - - - name: Get GitHub OIDC token - id: github-oidc - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: core.setOutput('github_token', await core.getIDToken()); - - - name: Build and upload gem artifacts - env: - URL: https://pkg.stainless.com/s - AUTH: ${{ steps.github-oidc.outputs.github_token }} - SHA: ${{ github.sha }} - PACKAGE_NAME: openai - run: ./scripts/utils/upload-artifact.sh - lint: timeout-minutes: 10 name: lint (rubocop) @@ -159,7 +124,6 @@ jobs: name: ci-required # Keep branch protection on one stable context as the job matrix evolves. needs: - - stainless-artifact - lint - typecheck - validate-rbs @@ -171,14 +135,12 @@ jobs: steps: - name: Check required job results env: - ARTIFACT_RESULT: ${{ needs.stainless-artifact.result }} LINT_RESULT: ${{ needs.lint.result }} TYPECHECK_RESULT: ${{ needs.typecheck.result }} RBS_VALIDATION_RESULT: ${{ needs.validate-rbs.result }} PACKAGE_RESULT: ${{ needs.package.result }} TEST_RESULT: ${{ needs.test-ruby.result }} run: | - test "$ARTIFACT_RESULT" = "success" || test "$ARTIFACT_RESULT" = "skipped" test "$LINT_RESULT" = "success" test "$TYPECHECK_RESULT" = "success" test "$RBS_VALIDATION_RESULT" = "success" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 333f1c22..8f2eb9af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,12 @@ name: CI -# This workflow is shared by the Stainless staging repository and the OpenAI -# production repository. Stainless feature branches use push runs so they can -# publish installable artifacts; production feature branches use PR runs so a -# commit is validated only once. on: push: branches: - '**' - '!gh-readonly-queue/**' - '!integrated/**' - - '!stl-preview-head/**' - - '!stl-preview-base/**' - - '!generated' - - '!codegen/**' - - 'codegen/stl/**' pull_request: - branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' merge_group: types: - checks_requested @@ -33,23 +21,12 @@ jobs: checks: name: ci if: >- - (startsWith(github.repository, 'stainless-sdks/') && - (github.event_name == 'push' || github.event.pull_request.head.repo.fork)) || - (!startsWith(github.repository, 'stainless-sdks/') && - (github.event_name == 'pull_request' || - github.event_name == 'merge_group' || - github.ref == 'refs/heads/main' || - github.ref == 'refs/heads/next')) + github.event_name == 'pull_request' || + github.event_name == 'merge_group' || + github.ref == 'refs/heads/main' || + github.ref == 'refs/heads/next' permissions: contents: read - id-token: write # Allow the called artifact job to mint its Stainless upload token. uses: ./.github/workflows/ci-checks.yml with: - runner: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - publish-stainless-artifact: >- - ${{ - github.repository == 'stainless-sdks/openai-ruby' && - (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && - (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') && - !startsWith(github.ref, 'refs/heads/stl/') - }} + runner: ubuntu-latest diff --git a/.stats.yml b/.stats.yml deleted file mode 100644 index 284e122e..00000000 --- a/.stats.yml +++ /dev/null @@ -1,4 +0,0 @@ -configured_endpoints: 278 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai/openai-31fcc3c97e5c432754bf0b72c5d5abbe1493573f0ca888af914baf81188b453e.yml -openapi_spec_hash: f867849a0c2a9c9b838d21f1c720a9e3 -config_hash: fce3820c4f95b555e486349bc7d297d8 diff --git a/SECURITY.md b/SECURITY.md index f04523e4..ece2734f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,28 +2,12 @@ ## Reporting Security Issues -This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously and encourages you to report any security vulnerability promptly so that appropriate action can be taken. - -To report a security issue, please contact the Stainless team at security@stainless.com. +Please report potential security vulnerabilities through OpenAI's +[coordinated vulnerability disclosure process](https://openai.com/policies/coordinated-vulnerability-disclosure-policy). +For questions about that process, contact disclosure@openai.com. ## Responsible Disclosure -We appreciate the efforts of security researchers and individuals who help us maintain the security of -SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible -disclosure practices by allowing us a reasonable amount of time to investigate and address the issue -before making any information public. - -## Reporting Non-SDK Related Security Issues - -If you encounter security issues that are not directly related to SDKs but pertain to the services -or products provided by OpenAI, please follow the respective company's security reporting guidelines. - -### OpenAI Terms and Policies - -Our Security Policy can be found at [Security Policy URL](https://openai.com/policies/coordinated-vulnerability-disclosure-policy). - -Please contact disclosure@openai.com for any questions or concerns regarding the security of our services. - ---- - -Thank you for helping us keep the SDKs and systems they interact with secure. +Please allow OpenAI a reasonable amount of time to investigate and address the +issue before making information public. Thank you for helping us keep this SDK +and the systems it interacts with secure. diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index c7bd455b..d42cec38 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -28325,6 +28325,7 @@ components: - tenant.workload_identity.mapping.archived - tenant.workload_identity.binding.created - tenant.workload_identity.principal.provisioned + - tenant.workload_identity.access_token.issued - tenant.admin_api_key.created - tenant.admin_api_key.updated - tenant.admin_api_key.deleted diff --git a/lib/openai/helpers/streaming/chat_completion_stream.rb b/lib/openai/helpers/streaming/chat_completion_stream.rb index 03d2801d..c4862707 100644 --- a/lib/openai/helpers/streaming/chat_completion_stream.rb +++ b/lib/openai/helpers/streaming/chat_completion_stream.rb @@ -384,7 +384,9 @@ def parseable_input? def model_dump(obj) if obj.is_a?(OpenAI::Internal::Type::BaseModel) - obj.deep_to_h + # Streaming mutates typed nested accessors without replacing their raw values. + # Accumulate from those accessors so a later delta does not discard mutations. + OpenAI::Internal::Type::BaseModel.recursively_to_h(obj, convert: true) elsif obj.respond_to?(:to_h) obj.to_h else diff --git a/lib/openai/helpers/streaming/response_stream.rb b/lib/openai/helpers/streaming/response_stream.rb index 0a326b26..462aefea 100644 --- a/lib/openai/helpers/streaming/response_stream.rb +++ b/lib/openai/helpers/streaming/response_stream.rb @@ -41,19 +41,7 @@ def get_final_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 + get_final_response.output_text end private @@ -63,7 +51,7 @@ def iterator @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 + if @starting_after.nil? || event[:sequence_number] > @starting_after y << event end end diff --git a/lib/openai/internal/type/base_model.rb b/lib/openai/internal/type/base_model.rb index c68d0f97..7d6f6655 100644 --- a/lib/openai/internal/type/base_model.rb +++ b/lib/openai/internal/type/base_model.rb @@ -89,19 +89,19 @@ def fields OpenAI::Internal::Type::Converter.coerce(target, value, state: state) end error = state.fetch(:error) - @coerced.store(name_sym, error || true) - stored = - case [target, error] - in [OpenAI::Internal::Type::Converter | Symbol, nil] - if value in ^target - value - else - coerced - end - else + converted = + if value in ^target value + else + coerced end - @data.store(name_sym, stored) + _store_field( + name_sym, + value, + converted: converted, + cache: error.nil? && target.is_a?(OpenAI::Internal::Type::Converter), + error: error + ) end # rubocop:disable Style/CaseEquality @@ -111,7 +111,7 @@ def fields case @coerced[name_sym] in true | false if OpenAI::Internal::Type::Converter === target - @data.fetch(name_sym) + @converted.fetch(name_sym) in ::StandardError => e raise OpenAI::Errors::ConversionError.new( on: self.class, @@ -298,7 +298,6 @@ def coerce(value, state:) keys = val.keys.to_set instance = new data = instance.to_h - viability = instance.instance_variable_get(:@coerced) # rubocop:disable Metrics/BlockLength error = state.fetch(:error) @@ -336,8 +335,13 @@ def coerce(value, state:) end error ||= field_error - viability.store(name, field_error || true) - data.store(name, converted) + instance._store_field( + name, + item, + converted: converted, + cache: field_error.nil? && target.is_a?(OpenAI::Internal::Type::Converter), + error: field_error + ) end # rubocop:enable Metrics/BlockLength @@ -360,6 +364,7 @@ def dump(value, state:) return super end + model = value if value.is_a?(OpenAI::Internal::Type::BaseModel) acc = {} coerced.each do |key, val| @@ -374,7 +379,8 @@ def dump(value, state:) next else target = type_fn.call - acc.store(api_name, OpenAI::Internal::Type::Converter.dump(target, val, state: state)) + item = model ? model._value_for_dump(name) : val + acc.store(api_name, OpenAI::Internal::Type::Converter.dump(target, item, state: state)) end end end @@ -519,6 +525,47 @@ def to_yaml(*a) = OpenAI::Internal::Type::Converter.dump(self.class, self).to_ya def encode_with(coder) coder["data"] = @data coder["coerced"] = @coerced + coder["converted"] = @converted + end + + # Restores the converted cache while remaining compatible with YAML written by + # versions that stored converted values directly in data. + # + # @api private + # + # @param coder [Psych::Coder] + # @return [void] + def init_with(coder) + @data = coder["data"] || {} + @coerced = coder["coerced"] || {} + @converted = coder["converted"] || @data.dup + @last_response = nil + end + + # @api private + # + # Returns the converted value used for request serialization without changing + # the raw value exposed by #[] and #to_h. + # + # @param name [Symbol] + # @return [Object] + def _value_for_dump(name) = @converted.fetch(name) { @data.fetch(name) } + + # @api private + # + # Stores the raw field value separately from its converted accessor value. + # + # @param name [Symbol] + # @param value [Object] + # @param converted [Object] + # @param cache [Boolean] + # @param error [StandardError, nil] + # + # @return [Object] + def _store_field(name, value, converted:, cache:, error:) + @coerced.store(name, error || true) + cache ? @converted.store(name, converted) : @converted.delete(name) + @data.store(name, value) end # Create a new instance of a model. @@ -527,6 +574,7 @@ def encode_with(coder) def initialize(data = {}) @data = {} @coerced = {} + @converted = {} @last_response = nil OpenAI::Internal::Util.coerce_hash!(data).each do if self.class.known_fields.key?(_1) diff --git a/lib/openai/internal/type/union.rb b/lib/openai/internal/type/union.rb index 81243bbb..b38e6c6c 100644 --- a/lib/openai/internal/type/union.rb +++ b/lib/openai/internal/type/union.rb @@ -90,27 +90,101 @@ def variants = derefed_variants.map { _2 } # # @return [OpenAI::Internal::Type::Converter, Class, nil] private def resolve_variant(value) - case [@discriminator, value] - in [_, OpenAI::Internal::Type::BaseModel] + case value + in OpenAI::Internal::Type::BaseModel value.class + else + key = discriminator_value(value) + return nil if key == OpenAI::Internal::OMIT || key.nil? + + keyed_matches = known_variants.select { |known_key,| known_key == key } + return keyed_matches.fetch(0).fetch(1).call if keyed_matches.one? + return nil if keyed_matches.any? + + unkeyed_matches = known_variants.select do |known_key, variant_fn| + known_key.nil? && variant_discriminator_matches?(variant_fn.call, key) + end + unkeyed_matches.fetch(0).fetch(1).call if unkeyed_matches.one? + end + end + + # @api private + # + # @param value [Object] + # + # @return [Object] + private def discriminator_value(value) + case [@discriminator, value] in [Symbol, Hash] key = value.fetch(@discriminator) do value.fetch(@discriminator.to_s, OpenAI::Internal::OMIT) end - - return nil if key == OpenAI::Internal::OMIT - - key = key.to_sym if key.is_a?(String) - _, found = known_variants.find { |k,| k == key } - found&.call + key.is_a?(String) ? key.to_sym : key else - nil + OpenAI::Internal::OMIT end end + # @api private + # + # Missing and nil discriminators retain structural inference for generated + # variants whose discriminator field is optional or nullable. Duplicate keyed + # variants are compared structurally. A present but unknown discriminator tries + # explicit unkeyed variants before preserving the original value. + # + # @param discriminator [Object] + # + # @return [ArrayObject})>] + private def fallback_variants(discriminator) + return known_variants if discriminator == OpenAI::Internal::OMIT || discriminator.nil? + + keyed_variants = known_variants.select { |known_key,| known_key == discriminator } + return keyed_variants unless keyed_variants.empty? + + unknown = [nil, -> { OpenAI::Internal::Type::Unknown }, {}] + known_variants.select { |known_key,| known_key.nil? }.append(unknown) + end + # rubocop:disable Style/HashEachMethods # rubocop:disable Style/CaseEquality + # @api private + # + # Some generated variants are unkeyed because one model accepts discriminator + # values through an enum. Resolve recognized values without structurally matching + # those models against genuinely unknown discriminator values. + # + # @param target [Object] + # + # @param discriminator [Object] + # + # @return [Boolean] + private def variant_discriminator_matches?(target, discriminator) + return false unless target.is_a?(Class) && target <= OpenAI::Internal::Type::BaseModel + + field = target.known_fields.values.find { _1.fetch(:api_name) == @discriminator } + return false unless field + + field.fetch(:type_fn).call === discriminator + end + + # @api private + # + # @param target [Object] + # + # @param value [Object] + # + # @return [Integer] + private def matched_field_count(target, value) + return 0 unless target.is_a?(Class) && target <= OpenAI::Internal::Type::BaseModel + return 0 unless value.is_a?(Hash) + + target.known_fields.count do |name, field| + api_name = field.fetch(:api_name) + value.key?(name) || value.key?(name.to_s) || value.key?(api_name) || value.key?(api_name.to_s) + end + end + # @api public # # @param other [Object] @@ -165,9 +239,16 @@ def coerce(value, state:) end exactness = state.fetch(:exactness) + discriminator = discriminator_value(value) + candidates = fallback_variants(discriminator) + duplicate_keyed_candidates = + candidates.length > 1 && + discriminator != OpenAI::Internal::OMIT && + !discriminator.nil? && + candidates.all? { |known_key,| known_key == discriminator } alternatives = [] - known_variants.each do |_, variant_fn| + candidates.each do |_, variant_fn| target = variant_fn.call exact = state[:exactness] = {yes: 0, no: 0, maybe: 0} state[:branched] += 1 @@ -175,13 +256,16 @@ def coerce(value, state:) coerced, error = OpenAI::Internal::Type::Converter.coerce_with_error(target, value, state: state) yes, no, maybe = exact.values - if (no + maybe).zero? || (!strictness && yes.positive?) + viable = (no + maybe).zero? || (!strictness && yes.positive?) + if viable && !duplicate_keyed_candidates exact.each { exactness[_1] += _2 } state[:exactness] = exactness state[:error] = error return coerced - elsif maybe.positive? - alternatives << [[-yes, -maybe, no], exact, coerced, error] + elsif viable || maybe.positive? + invalid = duplicate_keyed_candidates && no.positive? ? 1 : 0 + matched = duplicate_keyed_candidates ? matched_field_count(target, value) : 0 + alternatives << [[invalid, -matched, -yes, -maybe, no], exact, coerced, error] end end diff --git a/lib/openai/models/admin/organization/audit_log_list_params.rb b/lib/openai/models/admin/organization/audit_log_list_params.rb index 82bdef94..5b2342e9 100644 --- a/lib/openai/models/admin/organization/audit_log_list_params.rb +++ b/lib/openai/models/admin/organization/audit_log_list_params.rb @@ -229,6 +229,7 @@ module EventType TENANT_WORKLOAD_IDENTITY_MAPPING_ARCHIVED = :"tenant.workload_identity.mapping.archived" TENANT_WORKLOAD_IDENTITY_BINDING_CREATED = :"tenant.workload_identity.binding.created" TENANT_WORKLOAD_IDENTITY_PRINCIPAL_PROVISIONED = :"tenant.workload_identity.principal.provisioned" + TENANT_WORKLOAD_IDENTITY_ACCESS_TOKEN_ISSUED = :"tenant.workload_identity.access_token.issued" TENANT_ADMIN_API_KEY_CREATED = :"tenant.admin_api_key.created" TENANT_ADMIN_API_KEY_UPDATED = :"tenant.admin_api_key.updated" TENANT_ADMIN_API_KEY_DELETED = :"tenant.admin_api_key.deleted" diff --git a/lib/openai/models/admin/organization/audit_log_list_response.rb b/lib/openai/models/admin/organization/audit_log_list_response.rb index 0cc4164a..9a554c06 100644 --- a/lib/openai/models/admin/organization/audit_log_list_response.rb +++ b/lib/openai/models/admin/organization/audit_log_list_response.rb @@ -677,6 +677,7 @@ module Type TENANT_WORKLOAD_IDENTITY_MAPPING_ARCHIVED = :"tenant.workload_identity.mapping.archived" TENANT_WORKLOAD_IDENTITY_BINDING_CREATED = :"tenant.workload_identity.binding.created" TENANT_WORKLOAD_IDENTITY_PRINCIPAL_PROVISIONED = :"tenant.workload_identity.principal.provisioned" + TENANT_WORKLOAD_IDENTITY_ACCESS_TOKEN_ISSUED = :"tenant.workload_identity.access_token.issued" TENANT_ADMIN_API_KEY_CREATED = :"tenant.admin_api_key.created" TENANT_ADMIN_API_KEY_UPDATED = :"tenant.admin_api_key.updated" TENANT_ADMIN_API_KEY_DELETED = :"tenant.admin_api_key.deleted" diff --git a/lib/openai/models/responses/response.rb b/lib/openai/models/responses/response.rb index b2acdf0f..29e9b101 100644 --- a/lib/openai/models/responses/response.rb +++ b/lib/openai/models/responses/response.rb @@ -349,9 +349,9 @@ def output_text texts = [] output.each do |item| - next unless item.type == :message + next unless item.is_a?(OpenAI::Responses::ResponseOutputMessage) item.content.each do |content| - if content.type == :output_text + if content.is_a?(OpenAI::Responses::ResponseOutputText) texts << content.text end end diff --git a/rbi/openai/internal/type/base_model.rbi b/rbi/openai/internal/type/base_model.rbi index 4c26066e..0c777801 100644 --- a/rbi/openai/internal/type/base_model.rbi +++ b/rbi/openai/internal/type/base_model.rbi @@ -283,11 +283,34 @@ module OpenAI def to_yaml(*a) end + # @api private + sig do + params( + name: Symbol, + value: T.anything, + converted: T.anything, + cache: T::Boolean, + error: T.nilable(StandardError) + ).returns(T.anything) + end + def _store_field(name, value, converted:, cache:, error:) + end + # @api private sig { params(coder: T.untyped).void } def encode_with(coder) end + # @api private + sig { params(coder: T.untyped).void } + def init_with(coder) + end + + # @api private + sig { params(name: Symbol).returns(T.anything) } + def _value_for_dump(name) + end + # Create a new instance of a model. sig do params( diff --git a/rbi/openai/internal/type/union.rbi b/rbi/openai/internal/type/union.rbi index 4aa6836b..8b5e7bf6 100644 --- a/rbi/openai/internal/type/union.rbi +++ b/rbi/openai/internal/type/union.rbi @@ -70,6 +70,36 @@ module OpenAI private def resolve_variant(value) end + # @api private + sig { params(value: T.anything).returns(T.anything) } + private def discriminator_value(value) + end + + # @api private + sig do + params(discriminator: T.anything).returns( + T::Array[ + [ + T.nilable(Symbol), + T.proc.returns(OpenAI::Internal::Type::Converter::Input), + OpenAI::Internal::AnyHash + ] + ] + ) + end + private def fallback_variants(discriminator) + end + + # @api private + sig { params(target: T.anything, discriminator: T.anything).returns(T::Boolean) } + private def variant_discriminator_matches?(target, discriminator) + end + + # @api private + sig { params(target: T.anything, value: T.anything).returns(Integer) } + private def matched_field_count(target, value) + end + sig { params(other: T.anything).returns(T::Boolean) } def ===(other) end diff --git a/rbi/openai/models/admin/organization/audit_log_list_params.rbi b/rbi/openai/models/admin/organization/audit_log_list_params.rbi index cd92b3fa..0951c266 100644 --- a/rbi/openai/models/admin/organization/audit_log_list_params.rbi +++ b/rbi/openai/models/admin/organization/audit_log_list_params.rbi @@ -646,6 +646,11 @@ module OpenAI :"tenant.workload_identity.principal.provisioned", OpenAI::Admin::Organization::AuditLogListParams::EventType::TaggedSymbol ) + TENANT_WORKLOAD_IDENTITY_ACCESS_TOKEN_ISSUED = + T.let( + :"tenant.workload_identity.access_token.issued", + OpenAI::Admin::Organization::AuditLogListParams::EventType::TaggedSymbol + ) TENANT_ADMIN_API_KEY_CREATED = T.let( :"tenant.admin_api_key.created", diff --git a/rbi/openai/models/admin/organization/audit_log_list_response.rbi b/rbi/openai/models/admin/organization/audit_log_list_response.rbi index 0e7134b8..949891aa 100644 --- a/rbi/openai/models/admin/organization/audit_log_list_response.rbi +++ b/rbi/openai/models/admin/organization/audit_log_list_response.rbi @@ -1778,6 +1778,11 @@ module OpenAI :"tenant.workload_identity.principal.provisioned", OpenAI::Models::Admin::Organization::AuditLogListResponse::Type::TaggedSymbol ) + TENANT_WORKLOAD_IDENTITY_ACCESS_TOKEN_ISSUED = + T.let( + :"tenant.workload_identity.access_token.issued", + OpenAI::Models::Admin::Organization::AuditLogListResponse::Type::TaggedSymbol + ) TENANT_ADMIN_API_KEY_CREATED = T.let( :"tenant.admin_api_key.created", diff --git a/scripts/mock b/scripts/mock index efc647ae..9516c440 100755 --- a/scripts/mock +++ b/scripts/mock @@ -17,12 +17,13 @@ elif [[ -f .castiron.stats.yml && -f api_reference/openapi.transformed.yml ]]; t exit 1 fi else - URL="$(grep 'openapi_spec_url' .stats.yml | cut -d' ' -f2)" + echo "Error: No OpenAPI spec path provided and no local transformed OpenAPI spec found" >&2 + exit 1 fi # Check if the URL is empty if [ -z "$URL" ]; then - echo "Error: No OpenAPI spec path/url provided or found in .stats.yml" + echo "Error: No OpenAPI spec path or URL provided" exit 1 fi diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh deleted file mode 100755 index b93d57fd..00000000 --- a/scripts/utils/upload-artifact.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -# ANSI Color Codes -GREEN='\033[32m' -RED='\033[31m' -NC='\033[0m' # No Color - -DIST_DIR="dist" - -log_error() { - local msg="$1" - local headers="$2" - local body="$3" - echo -e "${RED}${msg}${NC}" - [[ -f "$headers" ]] && echo -e "${RED}Headers:$(cat "$headers")${NC}" - echo -e "${RED}Body: ${body}${NC}" - exit 1 -} - -upload_file() { - local file_name="$1" - local tmp_headers - tmp_headers=$(mktemp) - - if [ -f "$file_name" ]; then - echo -e "${GREEN}Processing file: $file_name${NC}" - pkg_file_name="${file_name#"${DIST_DIR}/"}" - - # Get signed URL for uploading artifact file - signed_url_response=$(curl -X POST -G "$URL" \ - -sS --retry 5 \ - -D "$tmp_headers" \ - --data-urlencode "filename=$pkg_file_name" \ - -H "Authorization: Bearer $AUTH" \ - -H "Content-Type: application/json") - - # Validate JSON and extract URL - if ! signed_url=$(echo "$signed_url_response" | jq -e -r '.url' 2>/dev/null) || [[ "$signed_url" == "null" ]]; then - log_error "Failed to get valid signed URL" "$tmp_headers" "$signed_url_response" - fi - - # Set content-type based on file extension - local extension="${file_name##*.}" - local content_type - case "$extension" in - gem) content_type="application/octet-stream" ;; - gz) content_type="application/gzip" ;; - rz) content_type="application/octet-stream" ;; - html) content_type="text/html" ;; - *) content_type="application/octet-stream" ;; - esac - - # Upload file - upload_response=$(curl -v -X PUT \ - --retry 5 \ - --retry-all-errors \ - -D "$tmp_headers" \ - -H "Content-Type: $content_type" \ - --data-binary "@${file_name}" "$signed_url" 2>&1) - - if ! echo "$upload_response" | grep -q "HTTP/[0-9.]* 200"; then - log_error "Failed to upload artifact file" "$tmp_headers" "$upload_response" - fi - - # Insert small throttle to reduce rate limiting risk - sleep 0.1 - fi -} - -walk_tree() { - local current_dir="$1" - - for entry in "$current_dir"/*; do - # Check that entry is valid - [ -e "$entry" ] || [ -h "$entry" ] || continue - - if [ -d "$entry" ]; then - walk_tree "$entry" - else - upload_file "$entry" - fi - done -} - -cd "$(dirname "$0")/../.." - -echo "::group::Building gem" -VERSION_FILE="lib/${PACKAGE_NAME}/version.rb" -if [[ ! -f "$VERSION_FILE" ]]; then - echo -e "${RED}Version file not found: ${VERSION_FILE}${NC}" - exit 1 -fi -SHORT_SHA="${SHA:0:7}" -sed -i.bak -E "s/(VERSION = \"[^\"]+)\"/\1.beta.${SHORT_SHA}\"/" "$VERSION_FILE" -rm -f "${VERSION_FILE}.bak" - -gem build -mkdir -p "${DIST_DIR}/gems" -mv ./*.gem "${DIST_DIR}/gems/" -echo "::endgroup::" - -echo "::group::Generating gem index" -gem generate_index --directory "$DIST_DIR" -echo "::endgroup::" - -echo "::group::Uploading to pkg.stainless.com" -walk_tree "$DIST_DIR" -echo "::endgroup::" - -echo -e "${GREEN}Gem artifacts uploaded to Stainless storage.${NC}" -echo -e "\033[32mInstallation: bundle remove openai && bundle add openai --source 'https://pkg.stainless.com/s/openai-ruby/$SHA'\033[0m" diff --git a/sig/openai/internal/type/base_model.rbs b/sig/openai/internal/type/base_model.rbs index 2f206e3b..24f57b5e 100644 --- a/sig/openai/internal/type/base_model.rbs +++ b/sig/openai/internal/type/base_model.rbs @@ -100,8 +100,20 @@ module OpenAI def to_yaml: (*top a) -> String + def _store_field: ( + Symbol name, + top value, + converted: top, + cache: bool, + error: StandardError? + ) -> top + def encode_with: (top coder) -> void + def init_with: (top coder) -> void + + def _value_for_dump: (Symbol name) -> top + def initialize: (?::Hash[Symbol, top] | instance data) -> void def self.inspect: (?depth: Integer) -> String diff --git a/sig/openai/internal/type/union.rbs b/sig/openai/internal/type/union.rbs index 6f209ac7..191d940b 100644 --- a/sig/openai/internal/type/union.rbs +++ b/sig/openai/internal/type/union.rbs @@ -27,6 +27,16 @@ module OpenAI top value ) -> OpenAI::Internal::Type::Converter::input? + private def self.discriminator_value: (top value) -> top + + private def self.fallback_variants: ( + top discriminator + ) -> ::Array[[Symbol?, (^-> OpenAI::Internal::Type::Converter::input), ::Hash[Symbol, top]]] + + private def self.variant_discriminator_matches?: (top target, top discriminator) -> bool + + private def self.matched_field_count: (top target, top value) -> Integer + def ===: (top other) -> bool def ==: (top other) -> bool diff --git a/sig/openai/models/admin/organization/audit_log_list_params.rbs b/sig/openai/models/admin/organization/audit_log_list_params.rbs index 8b5b0d37..2a7317c5 100644 --- a/sig/openai/models/admin/organization/audit_log_list_params.rbs +++ b/sig/openai/models/admin/organization/audit_log_list_params.rbs @@ -199,6 +199,7 @@ module OpenAI | :"tenant.workload_identity.mapping.archived" | :"tenant.workload_identity.binding.created" | :"tenant.workload_identity.principal.provisioned" + | :"tenant.workload_identity.access_token.issued" | :"tenant.admin_api_key.created" | :"tenant.admin_api_key.updated" | :"tenant.admin_api_key.deleted" @@ -346,6 +347,7 @@ module OpenAI TENANT_WORKLOAD_IDENTITY_MAPPING_ARCHIVED: :"tenant.workload_identity.mapping.archived" TENANT_WORKLOAD_IDENTITY_BINDING_CREATED: :"tenant.workload_identity.binding.created" TENANT_WORKLOAD_IDENTITY_PRINCIPAL_PROVISIONED: :"tenant.workload_identity.principal.provisioned" + TENANT_WORKLOAD_IDENTITY_ACCESS_TOKEN_ISSUED: :"tenant.workload_identity.access_token.issued" TENANT_ADMIN_API_KEY_CREATED: :"tenant.admin_api_key.created" TENANT_ADMIN_API_KEY_UPDATED: :"tenant.admin_api_key.updated" TENANT_ADMIN_API_KEY_DELETED: :"tenant.admin_api_key.deleted" diff --git a/sig/openai/models/admin/organization/audit_log_list_response.rbs b/sig/openai/models/admin/organization/audit_log_list_response.rbs index 5a41b61b..3bcfbefd 100644 --- a/sig/openai/models/admin/organization/audit_log_list_response.rbs +++ b/sig/openai/models/admin/organization/audit_log_list_response.rbs @@ -604,6 +604,7 @@ module OpenAI | :"tenant.workload_identity.mapping.archived" | :"tenant.workload_identity.binding.created" | :"tenant.workload_identity.principal.provisioned" + | :"tenant.workload_identity.access_token.issued" | :"tenant.admin_api_key.created" | :"tenant.admin_api_key.updated" | :"tenant.admin_api_key.deleted" @@ -751,6 +752,7 @@ module OpenAI TENANT_WORKLOAD_IDENTITY_MAPPING_ARCHIVED: :"tenant.workload_identity.mapping.archived" TENANT_WORKLOAD_IDENTITY_BINDING_CREATED: :"tenant.workload_identity.binding.created" TENANT_WORKLOAD_IDENTITY_PRINCIPAL_PROVISIONED: :"tenant.workload_identity.principal.provisioned" + TENANT_WORKLOAD_IDENTITY_ACCESS_TOKEN_ISSUED: :"tenant.workload_identity.access_token.issued" TENANT_ADMIN_API_KEY_CREATED: :"tenant.admin_api_key.created" TENANT_ADMIN_API_KEY_UPDATED: :"tenant.admin_api_key.updated" TENANT_ADMIN_API_KEY_DELETED: :"tenant.admin_api_key.deleted" diff --git a/test/openai/internal/type/base_model_raw_value_contract_test.rb b/test/openai/internal/type/base_model_raw_value_contract_test.rb new file mode 100644 index 00000000..9883fee6 --- /dev/null +++ b/test/openai/internal/type/base_model_raw_value_contract_test.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +# These tests protect the public raw-value contract of BaseModel. Typed accessors may +# coerce nested values, but #[], #to_h, and #deep_to_h must continue to expose the data +# supplied by the caller or returned by the API. +class OpenAI::Test::BaseModelRawValueContractTest < Minitest::Test + class Item < OpenAI::Internal::Type::BaseModel + required :count, Integer + required :type, const: :item + end + + module ItemOrInteger + extend OpenAI::Internal::Type::Union + + variant Item + variant Integer + end + + class Container < OpenAI::Internal::Type::BaseModel + optional :item, Item + optional :items, OpenAI::Internal::Type::ArrayOf[Item] + optional :items_by_name, OpenAI::Internal::Type::HashOf[Item] + optional :choice, ItemOrInteger + end + + def test_constructor_keeps_raw_nested_hash_while_accessor_returns_typed_model + raw_item = {count: "1", type: "item"} + container = Container.new(item: raw_item) + + assert_same(raw_item, container[:item]) + assert_same(raw_item, container.to_h.fetch(:item)) + assert_equal("item", container.to_h.fetch(:item).fetch(:type)) + + assert_instance_of(Item, container.item) + assert_equal(1, container.item.count) + assert_equal(:item, container.item.type) + end + + def test_assignment_keeps_raw_nested_hash_while_replacing_typed_accessor_value + container = Container.new(item: {count: "1", type: "item"}) + replacement = {count: "2", type: "item"} + + container.item = replacement + + assert_same(replacement, container[:item]) + assert_same(replacement, container.to_h.fetch(:item)) + assert_equal("item", container.to_h.fetch(:item).fetch(:type)) + assert_instance_of(Item, container.item) + assert_equal(2, container.item.count) + end + + def test_converter_coerce_keeps_raw_nested_hash_while_accessor_returns_typed_model + raw_item = {count: "1", type: "item"} + state = OpenAI::Internal::Type::Converter.new_coerce_state + + container = OpenAI::Internal::Type::Converter.coerce( + Container, + {item: raw_item}, + state: state + ) + + assert_same(raw_item, container[:item]) + assert_same(raw_item, container.to_h.fetch(:item)) + assert_equal("item", container.to_h.dig(:item, :type)) + assert_instance_of(Item, container.item) + assert_equal(1, container.item.count) + assert_nil(state.fetch(:error)) + end + + def test_array_and_map_accessors_are_typed_without_replacing_raw_collections + raw_items = [{count: "1", type: "item"}] + raw_items_by_name = {"first" => {count: "2", type: "item"}} + container = Container.new(items: raw_items, items_by_name: raw_items_by_name) + + assert_same(raw_items, container.to_h.fetch(:items)) + assert_same(raw_items_by_name, container.to_h.fetch(:items_by_name)) + assert_equal("item", container.to_h.dig(:items, 0, :type)) + assert_equal("item", container.to_h.dig(:items_by_name, "first", :type)) + + assert_instance_of(Item, container.items.fetch(0)) + assert_equal(1, container.items.fetch(0).count) + assert_instance_of(Item, container.items_by_name.fetch(:first)) + assert_equal(2, container.items_by_name.fetch(:first).count) + end + + def test_deep_to_h_recurses_through_raw_values_without_materializing_models + raw = { + item: {count: "1", type: "item"}, + items: [{count: "2", type: "item"}], + items_by_name: {"third" => {count: "3", type: "item"}} + } + container = Container.new(**raw) + + assert_equal(raw, container.deep_to_h) + assert_instance_of(Hash, container.deep_to_h.fetch(:item)) + assert_instance_of(Hash, container.deep_to_h.fetch(:items).fetch(0)) + assert_instance_of(Hash, container.deep_to_h.dig(:items_by_name, "third")) + end + + def test_generated_params_support_nested_fetch_chains_on_raw_discriminators + input = [{content: "hello", role: "user", type: "message"}] + params = OpenAI::Responses::ResponseCreateParams.new(input: input) + + assert_equal("message", params.to_h.fetch(:input).fetch(0).fetch(:type)) + assert_same(input, params[:input]) + assert_same(input, params.to_h.fetch(:input)) + assert_instance_of(OpenAI::Responses::EasyInputMessage, params.input.fetch(0)) + assert_equal(:message, params.input.fetch(0).type) + end + + def test_generated_beta_params_have_the_same_raw_value_contract + input = [{content: "hello", role: "user", type: "message"}] + params = OpenAI::Beta::ResponseCreateParams.new(input: input) + + assert_equal("message", params.to_h.fetch(:input).fetch(0).fetch(:type)) + assert_same(input, params[:input]) + assert_same(input, params.to_h.fetch(:input)) + assert_instance_of(OpenAI::Beta::BetaEasyInputMessage, params.input.fetch(0)) + assert_equal(:message, params.input.fetch(0).type) + end + + def test_response_models_keep_raw_nested_payloads_while_accessors_materialize_models + raw_output = { + id: "msg_123", + content: [], + role: "assistant", + status: "completed", + type: "message" + } + response = OpenAI::Responses::Response.new(output: [raw_output]) + + assert_same(raw_output, response.to_h.fetch(:output).fetch(0)) + assert_equal("message", response.to_h.dig(:output, 0, :type)) + assert_instance_of(OpenAI::Responses::ResponseOutputMessage, response.output.fetch(0)) + assert_equal(:message, response.output.fetch(0).type) + end + + def test_mutating_raw_input_does_not_rewrite_the_typed_accessor_snapshot + raw_item = {count: "1", type: "item"} + container = Container.new(item: raw_item) + + raw_item[:count] = "99" + raw_item[:future] = true + + assert_equal("99", container.to_h.dig(:item, :count)) + assert_equal(true, container.to_h.dig(:item, :future)) + assert_equal(1, container.item.count) + refute(container.item.to_h.key?(:future)) + end + + def test_already_typed_nested_models_preserve_identity_in_both_views + item = Item.new(count: 1, type: :item) + container = Container.new(item: item) + + assert_same(item, container[:item]) + assert_same(item, container.to_h.fetch(:item)) + assert_same(item, container.item) + end + + def test_failed_nested_conversion_keeps_raw_value_and_raises_from_accessor + invalid = {count: "not-an-integer", type: "item"} + container = Container.new(item: invalid) + + assert_same(invalid, container[:item]) + assert_same(invalid, container.to_h.fetch(:item)) + assert_raises(OpenAI::Errors::ConversionError) { container.item } + end + + def test_primitive_union_accessor_converts_without_replacing_raw_value + container = Container.new(choice: "42") + + assert_equal("42", container[:choice]) + assert_equal("42", container.to_h.fetch(:choice)) + assert_equal(42, container.choice) + end + + def test_yaml_round_trip_preserves_raw_and_typed_views + raw_item = {count: "1", type: "item"} + copy = YAML.unsafe_load(YAML.dump(Container.new(item: raw_item))) + + assert_equal(raw_item, copy.to_h.fetch(:item)) + assert_equal("item", copy.to_h.dig(:item, :type)) + assert_instance_of(Item, copy.item) + assert_equal(1, copy.item.count) + assert_equal(:item, copy.item.type) + end + + def test_yaml_from_before_the_converted_cache_remains_readable_and_assignable + legacy_yaml = <<~YAML + --- !ruby/object:OpenAI::Test::BaseModelRawValueContractTest::Container + data: + :item: !ruby/object:OpenAI::Test::BaseModelRawValueContractTest::Item + data: + :count: 1 + :type: :item + coerced: + :count: true + :type: true + coerced: + :item: true + YAML + + container = YAML.unsafe_load(legacy_yaml) + + assert_instance_of(Item, container.item) + assert_equal(1, container.item.count) + + replacement = {count: "2", type: "item"} + container.item = replacement + + assert_same(replacement, container.to_h.fetch(:item)) + assert_instance_of(Item, container.item) + assert_equal(2, container.item.count) + end + + def test_request_dump_uses_typed_nested_value_without_changing_raw_params + grader = { + input: [{content: "hello", role: "user"}], + model: "gpt-4o", + name: "score" + } + params = OpenAI::FineTuning::Alpha::GraderValidateParams.new(grader: grader) + + dumped, = OpenAI::FineTuning::Alpha::GraderValidateParams.dump_request(params) + + assert_same(grader, params.to_h.fetch(:grader)) + assert_instance_of(OpenAI::Graders::ScoreModelGrader, params.grader) + assert_equal(:score_model, dumped.dig(:grader, :type)) + assert_equal("user", params.to_h.dig(:grader, :input, 0, :role)) + assert_equal(:user, dumped.dig(:grader, :input, 0, :role)) + end + + def test_request_dump_translates_nested_api_names_without_changing_raw_params + text = {format_: {type: "text"}} + params = OpenAI::Responses::ResponseCreateParams.new( + input: "hello", + model: "gpt-4o", + text: text + ) + + dumped, = OpenAI::Responses::ResponseCreateParams.dump_request(params) + + assert_same(text, params.to_h.fetch(:text)) + assert_equal("text", params.to_h.dig(:text, :format_, :type)) + assert_equal("text", dumped.dig(:text, :format, :type)) + refute(dumped.fetch(:text).key?(:format_)) + end +end diff --git a/test/openai/internal/type/base_model_test.rb b/test/openai/internal/type/base_model_test.rb index 1f4848db..ab38fc4f 100644 --- a/test/openai/internal/type/base_model_test.rb +++ b/test/openai/internal/type/base_model_test.rb @@ -392,7 +392,7 @@ def test_coerce [M2, {a: "1990-09-19"}] => [{yes: 3, maybe: 1}, {a: "1990-09-19"}], [M2, {a: "1990-09-19", c: nil}] => [{yes: 2, maybe: 2}, {a: "1990-09-19", c: nil}], - [M3, {c: "c", d: "d"}] => [{yes: 3}, {c: :c, d: :d}], + [M3, {c: "c", d: "d"}] => [{yes: 3}, {c: "c", d: "d"}], [M3, {c: "d", d: "c"}] => [{yes: 1, maybe: 2}, {c: "d", d: "c"}], [M4, {c: 2}] => [{yes: 5}, {c: 2}], @@ -400,11 +400,11 @@ def test_coerce [M4, {b: nil, c: 2}] => [{yes: 4, maybe: 1}, {b: nil, c: 2}], [M5, {}] => [{yes: 3}, {}], - [M5, {c: "c"}] => [{yes: 3}, {c: :c}], - [M5, {d: "d"}] => [{yes: 3}, {d: :d}], + [M5, {c: "c"}] => [{yes: 3}, {c: "c"}], + [M5, {d: "d"}] => [{yes: 3}, {d: "d"}], [M5, {d: nil}] => [{yes: 2, no: 1}, {d: nil}], - [M6, {a: [{a: []}]}] => [{yes: 6}, -> { _1 in {a: [M6]} }] + [M6, {a: [{a: []}]}] => [{yes: 6}, {a: [{a: []}]}] } cases.each do |lhs, rhs| @@ -615,7 +615,7 @@ def test_coerce [U1, :b] => [{maybe: 1}, 2, :b], [U2, {type: :a}] => [{yes: 3}, 0, {t: :a}], - [U2, {type: "b"}] => [{yes: 3}, 0, {type: :b}], + [U2, {type: "b"}] => [{yes: 3}, 0, {type: "b"}], [U3, "one"] => [{yes: 1}, 2, "one"], [U4, "one"] => [{yes: 1}, 1, "one"], @@ -623,8 +623,8 @@ def test_coerce [U5, {a: []}] => [{yes: 3}, 2, {a: []}], [U6, {b: []}] => [{yes: 3}, 2, {b: []}], - [U5, {a: [{a: []}]}] => [{yes: 6}, 4, {a: [M4.new(a: [])]}], - [U5, {a: [{a: [{a: []}]}]}] => [{yes: 9}, 6, {a: [M4.new(a: [M4.new(a: [])])]}] + [U5, {a: [{a: []}]}] => [{yes: 6}, 4, {a: [{a: []}]}], + [U5, {a: [{a: [{a: []}]}]}] => [{yes: 9}, 6, {a: [{a: [{a: []}]}]}] } cases.each do |lhs, rhs| diff --git a/test/openai/internal/type/discriminated_union_contract_test.rb b/test/openai/internal/type/discriminated_union_contract_test.rb new file mode 100644 index 00000000..ae6267f0 --- /dev/null +++ b/test/openai/internal/type/discriminated_union_contract_test.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +# These tests protect discriminator behavior used by generated Responses and Realtime +# schemas. Those schemas include duplicate, optional, nullable, and unkeyed +# discriminators, so a simple discriminator hash lookup is not sufficient. +class OpenAI::Test::DiscriminatedUnionContractTest < Minitest::Test + class UniqueA < OpenAI::Internal::Type::BaseModel + required :type, const: :a + required :value, Integer + end + + class UniqueB < OpenAI::Internal::Type::BaseModel + required :type, const: :b + required :value, String + end + + module UniqueUnion + extend OpenAI::Internal::Type::Union + + discriminator :type + variant :a, UniqueA + variant :b, UniqueB + end + + class DuplicateLeft < OpenAI::Internal::Type::BaseModel + required :type, const: :duplicate + required :left, Integer + end + + class DuplicateRight < OpenAI::Internal::Type::BaseModel + required :type, const: :duplicate + required :right, String + end + + module DuplicateUnion + extend OpenAI::Internal::Type::Union + + discriminator :type + variant :duplicate, DuplicateLeft + variant :duplicate, DuplicateRight + end + + def test_unique_discriminator_accepts_symbol_and_string_keys_and_values + inputs = [ + {type: :a, value: 1}, + {type: "a", value: 1}, + {"type" => :a, value: 1}, + {"type" => "a", value: 1} + ] + + inputs.each do |input| + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce(UniqueUnion, input, state: state) + + assert_instance_of(UniqueA, output) + assert_equal(:a, output.type) + assert_equal(1, output.value) + assert_nil(state.fetch(:error)) + end + end + + def test_duplicate_discriminator_uses_structure_instead_of_first_registration + left_input = {type: "duplicate", left: "1"} + right_input = {type: "duplicate", right: "value"} + + left_state = OpenAI::Internal::Type::Converter.new_coerce_state + left = OpenAI::Internal::Type::Converter.coerce(DuplicateUnion, left_input, state: left_state) + right_state = OpenAI::Internal::Type::Converter.new_coerce_state + right = OpenAI::Internal::Type::Converter.coerce(DuplicateUnion, right_input, state: right_state) + + assert_instance_of(DuplicateLeft, left) + assert_equal(1, left.left) + assert_nil(left_state.fetch(:error)) + assert_instance_of(DuplicateRight, right) + assert_equal("value", right.right) + assert_nil(right_state.fetch(:error)) + end + + def test_responses_input_message_variants_with_the_same_tag_choose_the_most_specific_shape + cases = [ + [ + {content: "hello", role: "user", type: "message"}, + OpenAI::Responses::EasyInputMessage + ], + [ + {content: [], role: "developer", status: "completed", type: "message"}, + OpenAI::Responses::ResponseInputItem::Message + ], + [ + {id: "msg_123", content: [], role: "assistant", status: "completed", type: "message"}, + OpenAI::Responses::ResponseOutputMessage + ] + ] + + cases.each do |input, expected_class| + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Responses::ResponseInputItem, + input, + state: state + ) + + assert_instance_of(expected_class, output) + assert_nil(state.fetch(:error)) + end + end + + def test_beta_input_message_variants_with_the_same_tag_choose_the_most_specific_shape + cases = [ + [ + {content: "hello", role: "user", type: "message"}, + OpenAI::Beta::BetaEasyInputMessage + ], + [ + {content: [], role: "developer", status: "completed", type: "message"}, + OpenAI::Beta::BetaResponseInputItem::Message + ], + [ + {id: "msg_123", content: [], role: "assistant", status: "completed", type: "message"}, + OpenAI::Beta::BetaResponseOutputMessage + ] + ] + + cases.each do |input, expected_class| + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Beta::BetaResponseInputItem, + input, + state: state + ) + + assert_instance_of(expected_class, output) + assert_nil(state.fetch(:error)) + end + end + + def test_responses_item_message_variants_with_the_same_tag_use_required_fields + cases = [ + [ + {id: "msg_in", content: [], role: "user", type: "message"}, + OpenAI::Responses::ResponseInputMessageItem + ], + [ + {id: "msg_out", content: [], role: "assistant", status: "completed", type: "message"}, + OpenAI::Responses::ResponseOutputMessage + ] + ] + + cases.each do |input, expected_class| + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce(OpenAI::Responses::ResponseItem, input, state: state) + + assert_instance_of(expected_class, output) + assert_nil(state.fetch(:error)) + end + end + + def test_beta_item_message_variants_with_the_same_tag_use_required_fields + cases = [ + [ + {id: "msg_in", content: [], role: "user", type: "message"}, + OpenAI::Beta::BetaResponseInputMessageItem + ], + [ + {id: "msg_out", content: [], role: "assistant", status: "completed", type: "message"}, + OpenAI::Beta::BetaResponseOutputMessage + ] + ] + + cases.each do |input, expected_class| + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce(OpenAI::Beta::BetaResponseItem, input, state: state) + + assert_instance_of(expected_class, output) + assert_nil(state.fetch(:error)) + end + end + + def test_realtime_message_variants_with_the_same_tag_use_role + cases = [ + ["system", OpenAI::Realtime::RealtimeConversationItemSystemMessage], + ["user", OpenAI::Realtime::RealtimeConversationItemUserMessage], + ["assistant", OpenAI::Realtime::RealtimeConversationItemAssistantMessage] + ] + + cases.each do |role, expected_class| + input = {content: [], role: role, type: "message"} + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Realtime::ConversationItem, + input, + state: state + ) + + assert_instance_of(expected_class, output) + assert_nil(state.fetch(:error)) + end + end + + def test_missing_discriminator_retains_structural_inference_for_responses_messages + input = {content: "hello", role: "user"} + state = OpenAI::Internal::Type::Converter.new_coerce_state + + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Responses::ResponseInputItem, + input, + state: state + ) + + assert_instance_of(OpenAI::Responses::EasyInputMessage, output) + assert_nil(output[:type]) + assert_nil(state.fetch(:error)) + end + + def test_nil_discriminator_retains_structural_inference_for_nullable_item_reference + input = {id: "item_123", type: nil} + state = OpenAI::Internal::Type::Converter.new_coerce_state + + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Responses::ResponseInputItem, + input, + state: state + ) + + assert_instance_of(OpenAI::Responses::ResponseInputItem::ItemReference, output) + assert_nil(output.type) + assert_nil(state.fetch(:error)) + end + + def test_missing_discriminator_retains_structural_inference_for_realtime_audio + cases = [ + [{}, OpenAI::Realtime::RealtimeAudioFormats::AudioPCM], + [{rate: 24_000}, OpenAI::Realtime::RealtimeAudioFormats::AudioPCM] + ] + + cases.each do |input, expected_class| + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Realtime::RealtimeAudioFormats, + input, + state: state + ) + + assert_instance_of(expected_class, output) + assert_nil(state.fetch(:error)) + end + end + + def test_missing_discriminator_retains_structural_inference_for_realtime_tools + input = {name: "weather"} + state = OpenAI::Internal::Type::Converter.new_coerce_state + + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Realtime::RealtimeToolsConfigUnion, + input, + state: state + ) + + assert_instance_of(OpenAI::Realtime::RealtimeFunctionTool, output) + assert_equal("weather", output.name) + assert_nil(state.fetch(:error)) + end + + def test_unknown_discriminator_is_a_successful_raw_passthrough + inputs = [ + {type: :future, sequence_number: 3}, + {type: "future", sequence_number: 3}, + {"type" => :future, "sequence_number" => 3}, + {"type" => "future", "sequence_number" => 3} + ] + + inputs.each do |input| + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce( + OpenAI::Responses::ResponseStreamEvent, + input, + state: state + ) + + assert_same(input, output) + assert_nil(state.fetch(:error)) + end + end + + def test_unknown_discriminator_nested_in_model_is_raw_and_does_not_raise + future_item = {id: "item_future", type: "future_item"} + response = OpenAI::Responses::Response.new(output: [future_item]) + + assert_same(future_item, response.output.fetch(0)) + assert_same(future_item, response.to_h.fetch(:output).fetch(0)) + end + + def test_known_unkeyed_discriminator_variants_remain_typed_with_unknown_nested_enum + cases = [ + [OpenAI::Responses::Tool, :web_search, OpenAI::Responses::WebSearchTool], + [OpenAI::Responses::Tool, :web_search_preview, OpenAI::Responses::WebSearchPreviewTool], + [OpenAI::Beta::BetaTool, :web_search, OpenAI::Beta::BetaWebSearchTool], + [OpenAI::Beta::BetaTool, :web_search_preview, OpenAI::Beta::BetaWebSearchPreviewTool] + ] + + cases.each do |union, type, expected_class| + input = {type: type.to_s, search_context_size: "future_size"} + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce(union, input, state: state) + + assert_instance_of(expected_class, output) + assert_equal(type, output.type) + assert_equal("future_size", output[:search_context_size]) + assert_equal("future_size", output.search_context_size) + assert_nil(state.fetch(:error)) + end + end + + def test_unkeyed_variants_do_not_claim_genuinely_unknown_discriminators + [OpenAI::Responses::Tool, OpenAI::Beta::BetaTool].each do |union| + input = {type: "future_tool", search_context_size: "future_size"} + state = OpenAI::Internal::Type::Converter.new_coerce_state + output = OpenAI::Internal::Type::Converter.coerce(union, input, state: state) + + assert_same(input, output) + assert_nil(state.fetch(:error)) + end + end +end diff --git a/test/openai/internal/type/nested_model_coercion_test.rb b/test/openai/internal/type/nested_model_coercion_test.rb index aeac9037..434e7665 100644 --- a/test/openai/internal/type/nested_model_coercion_test.rb +++ b/test/openai/internal/type/nested_model_coercion_test.rb @@ -37,23 +37,31 @@ class NullableContainer < OpenAI::Internal::Type::BaseModel required :nullable_item, Item, nil?: true end - def test_constructor_and_assignment_store_coerced_nested_models - model = Container.new(item: {a: "1", b: "2"}, items: [{a: "3", b: "4"}]) + def test_constructor_and_assignment_cache_coerced_models_separately_from_raw_values + item = {a: "1", b: "2"} + items = [{a: "3", b: "4"}] + model = Container.new(item: item, items: items) assert_instance_of(Item, model.item) assert_instance_of(Item, model.items.fetch(0)) - assert_instance_of(Item, model.to_h.fetch(:item)) - assert_instance_of(Item, model.to_h.fetch(:items).fetch(0)) + assert_same(item, model[:item]) + assert_same(items, model[:items]) + assert_same(item, model.to_h.fetch(:item)) + assert_same(items, model.to_h.fetch(:items)) assert_equal([1, 2], [model.item.a, model.item.b]) assert_equal([3, 4], [model.items.fetch(0).a, model.items.fetch(0).b]) - model.item = {a: "5", b: "6"} - model.items = [{a: "7", b: "8"}] + item = {a: "5", b: "6"} + items = [{a: "7", b: "8"}] + model.item = item + model.items = items assert_instance_of(Item, model.item) assert_instance_of(Item, model.items.fetch(0)) - assert_instance_of(Item, model.to_h.fetch(:item)) - assert_instance_of(Item, model.to_h.fetch(:items).fetch(0)) + assert_same(item, model[:item]) + assert_same(items, model[:items]) + assert_same(item, model.to_h.fetch(:item)) + assert_same(items, model.to_h.fetch(:items)) assert_equal([5, 6], [model.item.a, model.item.b]) assert_equal([7, 8], [model.items.fetch(0).a, model.items.fetch(0).b]) end @@ -78,13 +86,13 @@ def test_constructor_and_assignment_coerce_nested_maps_and_unions assert_instance_of(Item, model.reversed_choice) assert_equal( { - map: {second: {a: "5", b: "6"}}, + map: {"second" => {a: "5", b: "6"}}, choice: {a: "7", b: "8"}, reversed_choice: {a: "9", b: "10"} }, model.deep_to_h ) - assert_equal(model.deep_to_h, JSON.parse(model.to_json, symbolize_names: true)) + assert_equal("5", JSON.parse(model.to_json).dig("map", "second", "a")) end def test_setter_preserves_already_coerced_nested_model_identity @@ -151,14 +159,19 @@ def test_successful_nested_coercion_isolated_from_raw_input_mutation assert_equal(1, model.items.size) assert_equal(5, model.map.fetch(:item).a) refute(model.map.key?(:later)) + assert_equal("changed", model.to_h.fetch(:item).fetch(:a)) + assert_equal(2, model.to_h.fetch(:items).size) + assert(model.to_h.fetch(:map).key?(:later)) end - def test_nested_model_equality_and_hash_use_stored_coerced_values + def test_nested_model_equality_and_hash_use_stored_raw_values left = Container.new(item: {a: "1", b: "2"}, items: [{a: "3", b: "4"}]) right = Container.new(item: {a: "1", b: "2"}, items: [{a: "3", b: "4"}]) assert_equal(left, right) assert_equal(left.hash, right.hash) + + refute_equal(left, Container.new(item: {a: 1, b: 2}, items: [{a: 3, b: 4}])) end def test_failed_nested_model_coercion_preserves_input_and_error @@ -235,9 +248,9 @@ def test_rejected_union_variant_does_not_override_selected_coercion model = Container.new(choice: "1", reversed_choice: "2") assert_equal(1, model.choice) - assert_equal(1, model.to_h.fetch(:choice)) + assert_equal("1", model.to_h.fetch(:choice)) assert_equal(2, model.reversed_choice) - assert_equal(2, model.to_h.fetch(:reversed_choice)) + assert_equal("2", model.to_h.fetch(:reversed_choice)) end def test_selected_union_variant_preserves_its_conversion_error diff --git a/test/openai/models/responses/response_test.rb b/test/openai/models/responses/response_test.rb index 87915607..bdf5acdd 100644 --- a/test/openai/models/responses/response_test.rb +++ b/test/openai/models/responses/response_test.rb @@ -95,6 +95,26 @@ def test_output_text_with_mixed_empty_and_non_empty_text assert_equal("Non-empty", response.output_text) end + def test_output_text_skips_unknown_output_and_content_variants + response = build_response( + output: [ + {id: "future_item", type: "future_item"}, + { + id: "msg_known", + status: "completed", + role: "assistant", + type: "message", + content: [ + {type: "future_content", payload: "ignored"}, + {type: "output_text", text: "Known text", annotations: []} + ] + } + ] + ) + + assert_equal("Known text", response.output_text) + end + private def build_response(output:) diff --git a/test/openai/resources/responses/streaming_test.rb b/test/openai/resources/responses/streaming_test.rb index 832e3b10..ca273d6d 100644 --- a/test/openai/resources/responses/streaming_test.rb +++ b/test/openai/resources/responses/streaming_test.rb @@ -81,6 +81,38 @@ def test_basic_text_streaming end end + def test_unknown_event_type_is_not_coerced_to_a_known_event + created_event, remaining_events = basic_text_sse_response.split("\n\n", 2) + keepalive_event = <<~SSE + event: keepalive + data: {"sequence_number":3,"type":"keepalive"} + SSE + stub_streaming_response([created_event, keepalive_event, remaining_events].join("\n\n")) + + events = @client.responses.stream(**basic_params).to_a + keepalive = events.find { |event| event.is_a?(Hash) && event[:type] == "keepalive" } + + assert_equal({sequence_number: 3, type: "keepalive"}, keepalive) + refute(events.any? { |event| event.is_a?(OpenAI::Models::Responses::ResponseErrorEvent) }) + end + + def test_unknown_event_type_can_be_filtered_by_starting_after + created_event, remaining_events = basic_text_sse_response.split("\n\n", 2) + keepalive_event = <<~SSE + event: keepalive + data: {"sequence_number":3,"type":"keepalive"} + SSE + body = [created_event, keepalive_event, remaining_events].join("\n\n") + stub_request(:get, "http://localhost/responses/msg_001?stream=true") + .to_return(status: 200, headers: {"Content-Type" => "text/event-stream"}, body: body) + + events = @client.responses.stream(response_id: "msg_001", starting_after: 2).to_a + keepalive = events.find { |event| event.is_a?(Hash) && event[:type] == "keepalive" } + + assert_equal({sequence_number: 3, type: "keepalive"}, keepalive) + assert(events.all? { |event| event[:sequence_number] > 2 }) + end + def test_get_final_response stub_streaming_response(basic_text_sse_response) @@ -146,6 +178,22 @@ def test_get_output_text_with_no_text_content assert_equal("", text) end + def test_get_output_text_skips_unknown_output_and_content_variants + completed_event = <<~SSE + event: response.created + data: {"type":"response.created","sequence_number":1,"response":{"id":"msg_future","object":"realtime.response","status":"in_progress","status_details":null,"output":[],"usage":null,"metadata":null}} + + event: response.completed + data: {"type":"response.completed","sequence_number":2,"response":{"id":"msg_future","object":"realtime.response","status":"completed","status_details":null,"output":[{"id":"future_item","type":"future_item"},{"id":"item_known","type":"message","status":"completed","role":"assistant","content":[{"type":"future_content","payload":"ignored"},{"type":"output_text","text":"Known text","annotations":[]}]}],"usage":null,"metadata":null}} + + SSE + stub_streaming_response(completed_event) + + stream = @client.responses.stream(**basic_params) + + assert_equal("Known text", stream.get_output_text) + end + class WeatherModel < OpenAI::BaseModel required :location, String required :temperature, Integer @@ -532,7 +580,7 @@ def test_stream_with_both_text_and_tools final_response = stream.get_final_response text_output = final_response.output.find { |o| o.is_a?(OpenAI::Models::Responses::ResponseOutputMessage) } - text_content = text_output.content.find { |c| c[:type] == :output_text } + text_content = text_output.content.find { |c| c[:type] == "output_text" } assert_pattern do text_content[:parsed] => CalendarEvent[ name: "Conference",