From ce510974a425bcc16b22c87fbf7c06948ded82ca Mon Sep 17 00:00:00 2001 From: apcha-oai <228803254+apcha-oai@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:22:59 +0000 Subject: [PATCH] feat(api): support backend-mediated Realtime WebRTC calls Castiron-Internal-PR: https://github.com/openai/openai-java-internal/pull/49 Castiron-Source-SHA: b14df18425c937f604f2442e2bbb6c8af18d733b Castiron-Public-Base-SHA: 3c63360e04f6873a2386860ce0ac0ef3a783e955 --- .castiron.stats.yml | 10 +- .../com/openai/core/http/MultipartEncoding.kt | 29 ++ .../models/realtime/calls/CallCreateParams.kt | 487 ++++++++++++++++++ .../async/realtime/CallServiceAsync.kt | 27 + .../async/realtime/CallServiceAsyncImpl.kt | 44 ++ .../services/blocking/realtime/CallService.kt | 29 ++ .../blocking/realtime/CallServiceImpl.kt | 40 ++ .../openai/core/http/MultipartEncodingTest.kt | 62 +++ .../realtime/calls/CallCreateParamsTest.kt | 376 ++++++++++++++ .../async/realtime/CallServiceAsyncTest.kt | 166 ++++++ .../blocking/realtime/CallServiceTest.kt | 166 ++++++ 11 files changed, 1431 insertions(+), 5 deletions(-) create mode 100644 openai-java-core/src/main/kotlin/com/openai/core/http/MultipartEncoding.kt create mode 100644 openai-java-core/src/main/kotlin/com/openai/models/realtime/calls/CallCreateParams.kt create mode 100644 openai-java-core/src/test/kotlin/com/openai/core/http/MultipartEncodingTest.kt create mode 100644 openai-java-core/src/test/kotlin/com/openai/models/realtime/calls/CallCreateParamsTest.kt diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 4f42d0696..aa846039d 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: fd65dd19-7eac-493f-9f90-c62db36ea447 +generation_id: d5f4ed15-606a-40de-99cc-7624ca170180 openapi_spec_hash: b2c62b342b04685226037c980f2b5a7a openapi_transformed_spec_hash: ba44bf31326f01a1d886b7510dd9da8a -config_hash: 1e11a5becf7bc1c2e9e07b8654872dc4 -codegen_sha: 65429161dddc59cf304fa034eb8caccb05c4b1e6 -codegen_hash: 9302c8bf58629b0afe926ed88c80b2f20beb9a2555afe1febb68867f72ade4ea -public_codegen_sha: d9c7f61c82c73280993dd32b5a1ef3d2c0c8d490 +config_hash: 4fbbbcf377bcc17646cccb9fcfbac14a +codegen_sha: 41b7af5cc4a284522463cb9ac54053a29f9642ef +codegen_hash: 6c8ab5a5c306f169798c284065aa2844565e87ef175da1e1227914c9d7b4a9e7 +public_codegen_sha: 7661007907a5a6520bb17cffeeb1732809872c33 diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/MultipartEncoding.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/MultipartEncoding.kt new file mode 100644 index 000000000..64c01cf91 --- /dev/null +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/MultipartEncoding.kt @@ -0,0 +1,29 @@ +// File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +package com.openai.core.http + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.json.JsonMapper +import com.openai.core.MultipartField + +/** Preserve explicitly encoded fields as single, filename-less parts. */ +@JvmSynthetic +internal fun encodeMultipartFields( + jsonMapper: JsonMapper, + fields: Map>, + encodings: Map>, +): Map> = + fields + .filterValues { !it.value.isMissing() } + .mapValues { (name, field) -> + val encoding = encodings[name] ?: return@mapValues field + val node = jsonMapper.valueToTree(field.value) + val text = + if (encoding.second) { + jsonMapper.writeValueAsString(node) + } else { + require(node.isTextual) { "Multipart field $name must be a string" } + node.textValue() + } + MultipartField.builder().value(text).contentType(encoding.first).build() + } diff --git a/openai-java-core/src/main/kotlin/com/openai/models/realtime/calls/CallCreateParams.kt b/openai-java-core/src/main/kotlin/com/openai/models/realtime/calls/CallCreateParams.kt new file mode 100644 index 000000000..c06f374b9 --- /dev/null +++ b/openai-java-core/src/main/kotlin/com/openai/models/realtime/calls/CallCreateParams.kt @@ -0,0 +1,487 @@ +// File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +package com.openai.models.realtime.calls + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonProperty +import com.openai.core.ExcludeMissing +import com.openai.core.JsonMissing +import com.openai.core.JsonValue +import com.openai.core.MultipartField +import com.openai.core.Params +import com.openai.core.checkRequired +import com.openai.core.http.Headers +import com.openai.core.http.QueryParams +import com.openai.core.toImmutable +import com.openai.errors.OpenAIInvalidDataException +import com.openai.models.realtime.RealtimeSessionCreateRequest +import java.util.Collections +import java.util.Objects +import java.util.Optional + +/** + * Create a new Realtime API call over WebRTC and receive the SDP answer needed to complete the peer + * connection. + */ +class CallCreateParams +private constructor( + private val body: Body, + private val additionalHeaders: Headers, + private val additionalQueryParams: QueryParams, +) : Params { + + /** + * WebRTC Session Description Protocol (SDP) offer generated by the caller. + * + * @throws OpenAIInvalidDataException if the JSON field has an unexpected type or is + * unexpectedly missing or null (e.g. if the server responded with an unexpected value). + */ + fun sdp(): String = body.sdp() + + /** + * Realtime session object configuration. + * + * @throws OpenAIInvalidDataException if the JSON field has an unexpected type (e.g. if the + * server responded with an unexpected value). + */ + fun session(): Optional = body.session() + + /** + * Returns the raw multipart value of [sdp]. + * + * Unlike [sdp], this method doesn't throw if the multipart field has an unexpected type. + */ + fun _sdp(): MultipartField = body._sdp() + + /** + * Returns the raw multipart value of [session]. + * + * Unlike [session], this method doesn't throw if the multipart field has an unexpected type. + */ + fun _session(): MultipartField = body._session() + + fun _additionalBodyProperties(): Map = body._additionalProperties() + + /** Additional headers to send with the request. */ + fun _additionalHeaders(): Headers = additionalHeaders + + /** Additional query param to send with the request. */ + fun _additionalQueryParams(): QueryParams = additionalQueryParams + + fun toBuilder() = Builder().from(this) + + companion object { + + /** + * Returns a mutable builder for constructing an instance of [CallCreateParams]. + * + * The following fields are required: + * ```java + * .sdp() + * ``` + */ + @JvmStatic fun builder() = Builder() + } + + /** A builder for [CallCreateParams]. */ + class Builder internal constructor() { + + private var body: Body.Builder = Body.builder() + private var additionalHeaders: Headers.Builder = Headers.builder() + private var additionalQueryParams: QueryParams.Builder = QueryParams.builder() + + @JvmSynthetic + internal fun from(callCreateParams: CallCreateParams) = apply { + body = callCreateParams.body.toBuilder() + additionalHeaders = callCreateParams.additionalHeaders.toBuilder() + additionalQueryParams = callCreateParams.additionalQueryParams.toBuilder() + } + + /** + * Sets the entire request body. + * + * This is generally only useful if you are already constructing the body separately. + * Otherwise, it's more convenient to use the top-level setters instead: + * - [sdp] + * - [session] + */ + fun body(body: Body) = apply { this.body = body.toBuilder() } + + /** WebRTC Session Description Protocol (SDP) offer generated by the caller. */ + fun sdp(sdp: String) = apply { body.sdp(sdp) } + + /** + * Sets [Builder.sdp] to an arbitrary multipart value. + * + * You should usually call [Builder.sdp] with a well-typed [String] value instead. This + * method is primarily for setting the field to an undocumented or not yet supported value. + */ + fun sdp(sdp: MultipartField) = apply { body.sdp(sdp) } + + /** Realtime session object configuration. */ + fun session(session: RealtimeSessionCreateRequest) = apply { body.session(session) } + + /** + * Sets [Builder.session] to an arbitrary multipart value. + * + * You should usually call [Builder.session] with a well-typed + * [RealtimeSessionCreateRequest] value instead. This method is primarily for setting the + * field to an undocumented or not yet supported value. + */ + fun session(session: MultipartField) = apply { + body.session(session) + } + + fun additionalBodyProperties(additionalBodyProperties: Map) = apply { + body.additionalProperties(additionalBodyProperties) + } + + fun putAdditionalBodyProperty(key: String, value: JsonValue) = apply { + body.putAdditionalProperty(key, value) + } + + fun putAllAdditionalBodyProperties(additionalBodyProperties: Map) = + apply { + body.putAllAdditionalProperties(additionalBodyProperties) + } + + fun removeAdditionalBodyProperty(key: String) = apply { body.removeAdditionalProperty(key) } + + fun removeAllAdditionalBodyProperties(keys: Set) = apply { + body.removeAllAdditionalProperties(keys) + } + + fun additionalHeaders(additionalHeaders: Headers) = apply { + this.additionalHeaders.clear() + putAllAdditionalHeaders(additionalHeaders) + } + + fun additionalHeaders(additionalHeaders: Map>) = apply { + this.additionalHeaders.clear() + putAllAdditionalHeaders(additionalHeaders) + } + + fun putAdditionalHeader(name: String, value: String) = apply { + additionalHeaders.put(name, value) + } + + fun putAdditionalHeaders(name: String, values: Iterable) = apply { + additionalHeaders.put(name, values) + } + + fun putAllAdditionalHeaders(additionalHeaders: Headers) = apply { + this.additionalHeaders.putAll(additionalHeaders) + } + + fun putAllAdditionalHeaders(additionalHeaders: Map>) = apply { + this.additionalHeaders.putAll(additionalHeaders) + } + + fun replaceAdditionalHeaders(name: String, value: String) = apply { + additionalHeaders.replace(name, value) + } + + fun replaceAdditionalHeaders(name: String, values: Iterable) = apply { + additionalHeaders.replace(name, values) + } + + fun replaceAllAdditionalHeaders(additionalHeaders: Headers) = apply { + this.additionalHeaders.replaceAll(additionalHeaders) + } + + fun replaceAllAdditionalHeaders(additionalHeaders: Map>) = apply { + this.additionalHeaders.replaceAll(additionalHeaders) + } + + fun removeAdditionalHeaders(name: String) = apply { additionalHeaders.remove(name) } + + fun removeAllAdditionalHeaders(names: Set) = apply { + additionalHeaders.removeAll(names) + } + + fun additionalQueryParams(additionalQueryParams: QueryParams) = apply { + this.additionalQueryParams.clear() + putAllAdditionalQueryParams(additionalQueryParams) + } + + fun additionalQueryParams(additionalQueryParams: Map>) = apply { + this.additionalQueryParams.clear() + putAllAdditionalQueryParams(additionalQueryParams) + } + + fun putAdditionalQueryParam(key: String, value: String) = apply { + additionalQueryParams.put(key, value) + } + + fun putAdditionalQueryParams(key: String, values: Iterable) = apply { + additionalQueryParams.put(key, values) + } + + fun putAllAdditionalQueryParams(additionalQueryParams: QueryParams) = apply { + this.additionalQueryParams.putAll(additionalQueryParams) + } + + fun putAllAdditionalQueryParams(additionalQueryParams: Map>) = + apply { + this.additionalQueryParams.putAll(additionalQueryParams) + } + + fun replaceAdditionalQueryParams(key: String, value: String) = apply { + additionalQueryParams.replace(key, value) + } + + fun replaceAdditionalQueryParams(key: String, values: Iterable) = apply { + additionalQueryParams.replace(key, values) + } + + fun replaceAllAdditionalQueryParams(additionalQueryParams: QueryParams) = apply { + this.additionalQueryParams.replaceAll(additionalQueryParams) + } + + fun replaceAllAdditionalQueryParams(additionalQueryParams: Map>) = + apply { + this.additionalQueryParams.replaceAll(additionalQueryParams) + } + + fun removeAdditionalQueryParams(key: String) = apply { additionalQueryParams.remove(key) } + + fun removeAllAdditionalQueryParams(keys: Set) = apply { + additionalQueryParams.removeAll(keys) + } + + /** + * Returns an immutable instance of [CallCreateParams]. + * + * Further updates to this [Builder] will not mutate the returned instance. + * + * The following fields are required: + * ```java + * .sdp() + * ``` + * + * @throws IllegalStateException if any required field is unset. + */ + fun build(): CallCreateParams = + CallCreateParams(body.build(), additionalHeaders.build(), additionalQueryParams.build()) + } + + fun _body(): Map> = + (mapOf("sdp" to _sdp(), "session" to _session()) + + _additionalBodyProperties().mapValues { (_, value) -> MultipartField.of(value) }) + .toImmutable() + + override fun _headers(): Headers = additionalHeaders + + override fun _queryParams(): QueryParams = additionalQueryParams + + /** + * Parameters required to initiate a realtime call and receive the SDP answer needed to complete + * a WebRTC peer connection. Provide an SDP offer generated by your client and optionally + * configure the session that will answer the call. + */ + class Body + private constructor( + private val sdp: MultipartField, + private val session: MultipartField, + private val additionalProperties: MutableMap, + ) { + + /** + * WebRTC Session Description Protocol (SDP) offer generated by the caller. + * + * @throws OpenAIInvalidDataException if the JSON field has an unexpected type or is + * unexpectedly missing or null (e.g. if the server responded with an unexpected value). + */ + fun sdp(): String = sdp.value.getRequired("sdp") + + /** + * Realtime session object configuration. + * + * @throws OpenAIInvalidDataException if the JSON field has an unexpected type (e.g. if the + * server responded with an unexpected value). + */ + fun session(): Optional = session.value.getOptional("session") + + /** + * Returns the raw multipart value of [sdp]. + * + * Unlike [sdp], this method doesn't throw if the multipart field has an unexpected type. + */ + @JsonProperty("sdp") @ExcludeMissing fun _sdp(): MultipartField = sdp + + /** + * Returns the raw multipart value of [session]. + * + * Unlike [session], this method doesn't throw if the multipart field has an unexpected + * type. + */ + @JsonProperty("session") + @ExcludeMissing + fun _session(): MultipartField = session + + @JsonAnySetter + private fun putAdditionalProperty(key: String, value: JsonValue) { + additionalProperties.put(key, value) + } + + @JsonAnyGetter + @ExcludeMissing + fun _additionalProperties(): Map = + Collections.unmodifiableMap(additionalProperties) + + fun toBuilder() = Builder().from(this) + + companion object { + + /** + * Returns a mutable builder for constructing an instance of [Body]. + * + * The following fields are required: + * ```java + * .sdp() + * ``` + */ + @JvmStatic fun builder() = Builder() + } + + /** A builder for [Body]. */ + class Builder internal constructor() { + + private var sdp: MultipartField? = null + private var session: MultipartField = + MultipartField.of(JsonMissing.of()) + private var additionalProperties: MutableMap = mutableMapOf() + + @JvmSynthetic + internal fun from(body: Body) = apply { + sdp = body.sdp + session = body.session + additionalProperties = body.additionalProperties.toMutableMap() + } + + /** WebRTC Session Description Protocol (SDP) offer generated by the caller. */ + fun sdp(sdp: String) = sdp(MultipartField.of(sdp)) + + /** + * Sets [Builder.sdp] to an arbitrary multipart value. + * + * You should usually call [Builder.sdp] with a well-typed [String] value instead. This + * method is primarily for setting the field to an undocumented or not yet supported + * value. + */ + fun sdp(sdp: MultipartField) = apply { this.sdp = sdp } + + /** Realtime session object configuration. */ + fun session(session: RealtimeSessionCreateRequest) = session(MultipartField.of(session)) + + /** + * Sets [Builder.session] to an arbitrary multipart value. + * + * You should usually call [Builder.session] with a well-typed + * [RealtimeSessionCreateRequest] value instead. This method is primarily for setting + * the field to an undocumented or not yet supported value. + */ + fun session(session: MultipartField) = apply { + this.session = session + } + + fun additionalProperties(additionalProperties: Map) = apply { + this.additionalProperties.clear() + putAllAdditionalProperties(additionalProperties) + } + + fun putAdditionalProperty(key: String, value: JsonValue) = apply { + additionalProperties.put(key, value) + } + + fun putAllAdditionalProperties(additionalProperties: Map) = apply { + this.additionalProperties.putAll(additionalProperties) + } + + fun removeAdditionalProperty(key: String) = apply { additionalProperties.remove(key) } + + fun removeAllAdditionalProperties(keys: Set) = apply { + keys.forEach(::removeAdditionalProperty) + } + + /** + * Returns an immutable instance of [Body]. + * + * Further updates to this [Builder] will not mutate the returned instance. + * + * The following fields are required: + * ```java + * .sdp() + * ``` + * + * @throws IllegalStateException if any required field is unset. + */ + fun build(): Body = + Body(checkRequired("sdp", sdp), session, additionalProperties.toMutableMap()) + } + + private var validated: Boolean = false + + /** + * Validates that the types of all values in this object match their expected types + * recursively. + * + * This method is _not_ forwards compatible with new types from the API for existing fields. + * + * @throws OpenAIInvalidDataException if any value type in this object doesn't match its + * expected type. + */ + fun validate(): Body = apply { + if (validated) { + return@apply + } + + sdp() + session().ifPresent { it.validate() } + validated = true + } + + fun isValid(): Boolean = + try { + validate() + true + } catch (e: OpenAIInvalidDataException) { + false + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + return other is Body && + sdp == other.sdp && + session == other.session && + additionalProperties == other.additionalProperties + } + + private val hashCode: Int by lazy { Objects.hash(sdp, session, additionalProperties) } + + override fun hashCode(): Int = hashCode + + override fun toString() = + "Body{sdp=$sdp, session=$session, additionalProperties=$additionalProperties}" + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + return other is CallCreateParams && + body == other.body && + additionalHeaders == other.additionalHeaders && + additionalQueryParams == other.additionalQueryParams + } + + override fun hashCode(): Int = Objects.hash(body, additionalHeaders, additionalQueryParams) + + override fun toString() = + "CallCreateParams{body=$body, additionalHeaders=$additionalHeaders, additionalQueryParams=$additionalQueryParams}" +} diff --git a/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsync.kt b/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsync.kt index 570b013cc..b16b08528 100644 --- a/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsync.kt +++ b/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsync.kt @@ -6,6 +6,7 @@ import com.openai.core.ClientOptions import com.openai.core.RequestOptions import com.openai.core.http.HttpResponse import com.openai.models.realtime.calls.CallAcceptParams +import com.openai.models.realtime.calls.CallCreateParams import com.openai.models.realtime.calls.CallHangupParams import com.openai.models.realtime.calls.CallReferParams import com.openai.models.realtime.calls.CallRejectParams @@ -26,6 +27,19 @@ interface CallServiceAsync { */ fun withOptions(modifier: Consumer): CallServiceAsync + /** + * Create a new Realtime API call over WebRTC and receive the SDP answer needed to complete the + * peer connection. + */ + fun create(params: CallCreateParams): CompletableFuture = + create(params, RequestOptions.none()) + + /** @see create */ + fun create( + params: CallCreateParams, + requestOptions: RequestOptions = RequestOptions.none(), + ): CompletableFuture + /** Accept an incoming SIP call and configure the realtime session that will handle it. */ fun accept(callId: String, params: CallAcceptParams): CompletableFuture = accept(callId, params, RequestOptions.none()) @@ -138,6 +152,19 @@ interface CallServiceAsync { */ fun withOptions(modifier: Consumer): CallServiceAsync.WithRawResponse + /** + * Returns a raw HTTP response for `post /realtime/calls`, but is otherwise the same as + * [CallServiceAsync.create]. + */ + fun create(params: CallCreateParams): CompletableFuture = + create(params, RequestOptions.none()) + + /** @see create */ + fun create( + params: CallCreateParams, + requestOptions: RequestOptions = RequestOptions.none(), + ): CompletableFuture + /** * Returns a raw HTTP response for `post /realtime/calls/{call_id}/accept`, but is otherwise * the same as [CallServiceAsync.accept]. diff --git a/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsyncImpl.kt b/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsyncImpl.kt index d1f29bf9d..c7c97b379 100644 --- a/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsyncImpl.kt +++ b/openai-java-core/src/main/kotlin/com/openai/services/async/realtime/CallServiceAsyncImpl.kt @@ -13,10 +13,13 @@ import com.openai.core.http.HttpMethod import com.openai.core.http.HttpRequest import com.openai.core.http.HttpResponse import com.openai.core.http.HttpResponse.Handler +import com.openai.core.http.encodeMultipartFields import com.openai.core.http.json +import com.openai.core.http.multipartFormData import com.openai.core.http.parseable import com.openai.core.prepareAsync import com.openai.models.realtime.calls.CallAcceptParams +import com.openai.models.realtime.calls.CallCreateParams import com.openai.models.realtime.calls.CallHangupParams import com.openai.models.realtime.calls.CallReferParams import com.openai.models.realtime.calls.CallRejectParams @@ -36,6 +39,13 @@ class CallServiceAsyncImpl internal constructor(private val clientOptions: Clien override fun withOptions(modifier: Consumer): CallServiceAsync = CallServiceAsyncImpl(clientOptions.toBuilder().apply(modifier::accept).build()) + override fun create( + params: CallCreateParams, + requestOptions: RequestOptions, + ): CompletableFuture = + // post /realtime/calls + withRawResponse().create(params, requestOptions) + override fun accept( params: CallAcceptParams, requestOptions: RequestOptions, @@ -77,6 +87,40 @@ class CallServiceAsyncImpl internal constructor(private val clientOptions: Clien clientOptions.toBuilder().apply(modifier::accept).build() ) + override fun create( + params: CallCreateParams, + requestOptions: RequestOptions, + ): CompletableFuture { + val request = + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl(clientOptions.baseUrl()) + .addPathSegments("realtime", "calls") + .body( + multipartFormData( + clientOptions.jsonMapper, + encodeMultipartFields( + clientOptions.jsonMapper, + params._body(), + mapOf( + "sdp" to ("application/sdp" to false), + "session" to ("application/json" to true), + ), + ), + ) + ) + .build() + .prepareAsync( + clientOptions, + params, + SecurityOptions.builder().bearerAuth(true).build(), + ) + val requestOptions = requestOptions.applyDefaults(RequestOptions.from(clientOptions)) + return request + .thenComposeAsync { clientOptions.httpClient.executeAsync(it, requestOptions) } + .thenApply { response -> errorHandler.handle(response) } + } + private val acceptHandler: Handler = emptyHandler() override fun accept( diff --git a/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallService.kt b/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallService.kt index 30fa26846..2ffba983b 100644 --- a/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallService.kt +++ b/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallService.kt @@ -7,6 +7,7 @@ import com.openai.core.ClientOptions import com.openai.core.RequestOptions import com.openai.core.http.HttpResponse import com.openai.models.realtime.calls.CallAcceptParams +import com.openai.models.realtime.calls.CallCreateParams import com.openai.models.realtime.calls.CallHangupParams import com.openai.models.realtime.calls.CallReferParams import com.openai.models.realtime.calls.CallRejectParams @@ -26,6 +27,20 @@ interface CallService { */ fun withOptions(modifier: Consumer): CallService + /** + * Create a new Realtime API call over WebRTC and receive the SDP answer needed to complete the + * peer connection. + */ + @MustBeClosed + fun create(params: CallCreateParams): HttpResponse = create(params, RequestOptions.none()) + + /** @see create */ + @MustBeClosed + fun create( + params: CallCreateParams, + requestOptions: RequestOptions = RequestOptions.none(), + ): HttpResponse + /** Accept an incoming SIP call and configure the realtime session that will handle it. */ fun accept(callId: String, params: CallAcceptParams) = accept(callId, params, RequestOptions.none()) @@ -118,6 +133,20 @@ interface CallService { */ fun withOptions(modifier: Consumer): CallService.WithRawResponse + /** + * Returns a raw HTTP response for `post /realtime/calls`, but is otherwise the same as + * [CallService.create]. + */ + @MustBeClosed + fun create(params: CallCreateParams): HttpResponse = create(params, RequestOptions.none()) + + /** @see create */ + @MustBeClosed + fun create( + params: CallCreateParams, + requestOptions: RequestOptions = RequestOptions.none(), + ): HttpResponse + /** * Returns a raw HTTP response for `post /realtime/calls/{call_id}/accept`, but is otherwise * the same as [CallService.accept]. diff --git a/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallServiceImpl.kt b/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallServiceImpl.kt index df8e0acaf..6dd247a17 100644 --- a/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallServiceImpl.kt +++ b/openai-java-core/src/main/kotlin/com/openai/services/blocking/realtime/CallServiceImpl.kt @@ -13,10 +13,13 @@ import com.openai.core.http.HttpMethod import com.openai.core.http.HttpRequest import com.openai.core.http.HttpResponse import com.openai.core.http.HttpResponse.Handler +import com.openai.core.http.encodeMultipartFields import com.openai.core.http.json +import com.openai.core.http.multipartFormData import com.openai.core.http.parseable import com.openai.core.prepare import com.openai.models.realtime.calls.CallAcceptParams +import com.openai.models.realtime.calls.CallCreateParams import com.openai.models.realtime.calls.CallHangupParams import com.openai.models.realtime.calls.CallReferParams import com.openai.models.realtime.calls.CallRejectParams @@ -34,6 +37,10 @@ class CallServiceImpl internal constructor(private val clientOptions: ClientOpti override fun withOptions(modifier: Consumer): CallService = CallServiceImpl(clientOptions.toBuilder().apply(modifier::accept).build()) + override fun create(params: CallCreateParams, requestOptions: RequestOptions): HttpResponse = + // post /realtime/calls + withRawResponse().create(params, requestOptions) + override fun accept(params: CallAcceptParams, requestOptions: RequestOptions) { // post /realtime/calls/{call_id}/accept withRawResponse().accept(params, requestOptions) @@ -67,6 +74,39 @@ class CallServiceImpl internal constructor(private val clientOptions: ClientOpti clientOptions.toBuilder().apply(modifier::accept).build() ) + override fun create( + params: CallCreateParams, + requestOptions: RequestOptions, + ): HttpResponse { + val request = + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl(clientOptions.baseUrl()) + .addPathSegments("realtime", "calls") + .body( + multipartFormData( + clientOptions.jsonMapper, + encodeMultipartFields( + clientOptions.jsonMapper, + params._body(), + mapOf( + "sdp" to ("application/sdp" to false), + "session" to ("application/json" to true), + ), + ), + ) + ) + .build() + .prepare( + clientOptions, + params, + SecurityOptions.builder().bearerAuth(true).build(), + ) + val requestOptions = requestOptions.applyDefaults(RequestOptions.from(clientOptions)) + val response = clientOptions.httpClient.execute(request, requestOptions) + return errorHandler.handle(response) + } + private val acceptHandler: Handler = emptyHandler() override fun accept( diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/MultipartEncodingTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/MultipartEncodingTest.kt new file mode 100644 index 000000000..8d7ee89f4 --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/MultipartEncodingTest.kt @@ -0,0 +1,62 @@ +// File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +package com.openai.core.http + +import com.openai.core.JsonMissing +import com.openai.core.JsonValue +import com.openai.core.MultipartField +import com.openai.core.jsonMapper +import java.io.ByteArrayOutputStream +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class MultipartEncodingTest { + @Test + fun typedPartsPreserveJsonNullAndOmission() { + val mapper = jsonMapper() + val offer = "v=0\r\ns=Unicode π\r\n" + val encodings = + mapOf( + "offer" to ("application/sdp" to false), + "settings" to ("application/json" to true), + ) + for (settings in + listOf( + JsonValue.from(mapOf("future" to listOf(false, null, "π"))), + JsonValue.from(null), + JsonMissing.of(), + )) { + val fields = + encodeMultipartFields( + mapper, + mapOf( + "offer" to MultipartField.of(offer), + "settings" to MultipartField.of(settings), + ), + encodings, + ) + val body = multipartFormData(mapper, fields) + val output = ByteArrayOutputStream() + body.writeTo(output) + val wire = output.toString("UTF-8") + assertThat(wire).contains("Content-Type: application/sdp\r\n\r\n$offer\r\n") + assertThat(wire).doesNotContain("filename=") + if (settings.isMissing()) { + assertThat(fields).doesNotContainKey("settings") + } else { + assertThat(wire).contains("Content-Type: application/json") + assertThat( + mapper.readTree(fields.getValue("settings").value.asKnown().get() as String) + ) + .isEqualTo( + mapper.valueToTree(settings) + ) + } + assertThat(body.repeatable()).isTrue() + assertThat(body.contentLength()).isEqualTo(output.size().toLong()) + val retry = ByteArrayOutputStream() + body.writeTo(retry) + assertThat(retry.toByteArray()).isEqualTo(output.toByteArray()) + } + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/models/realtime/calls/CallCreateParamsTest.kt b/openai-java-core/src/test/kotlin/com/openai/models/realtime/calls/CallCreateParamsTest.kt new file mode 100644 index 000000000..cf5abfd31 --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/models/realtime/calls/CallCreateParamsTest.kt @@ -0,0 +1,376 @@ +// File generated from our OpenAPI spec by Castiron. See CONTRIBUTING.md for details. + +package com.openai.models.realtime.calls + +import com.openai.core.JsonValue +import com.openai.core.MultipartField +import com.openai.models.realtime.AudioTranscription +import com.openai.models.realtime.NoiseReductionType +import com.openai.models.realtime.RealtimeAudioConfig +import com.openai.models.realtime.RealtimeAudioConfigInput +import com.openai.models.realtime.RealtimeAudioConfigOutput +import com.openai.models.realtime.RealtimeAudioFormats +import com.openai.models.realtime.RealtimeAudioInputTurnDetection +import com.openai.models.realtime.RealtimeFunctionTool +import com.openai.models.realtime.RealtimeReasoning +import com.openai.models.realtime.RealtimeReasoningEffort +import com.openai.models.realtime.RealtimeSessionCreateRequest +import com.openai.models.realtime.RealtimeTruncation +import com.openai.models.responses.ResponsePrompt +import com.openai.models.responses.ToolChoiceOptions +import java.io.InputStream +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class CallCreateParamsTest { + + @Test + fun create() { + CallCreateParams.builder() + .sdp("sdp") + .session( + RealtimeSessionCreateRequest.builder() + .audio( + RealtimeAudioConfig.builder() + .input( + RealtimeAudioConfigInput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type(RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM) + .build() + ) + .noiseReduction( + RealtimeAudioConfigInput.NoiseReduction.builder() + .type(NoiseReductionType.NEAR_FIELD) + .build() + ) + .transcription( + AudioTranscription.builder() + .delay(AudioTranscription.Delay.MINIMAL) + .addKeyword("string") + .language("language") + .addLanguage("string") + .model(AudioTranscription.Model.WHISPER_1) + .prompt("prompt") + .build() + ) + .turnDetection( + RealtimeAudioInputTurnDetection.ServerVad.builder() + .createResponse(true) + .idleTimeoutMs(5000L) + .interruptResponse(true) + .prefixPaddingMs(0L) + .silenceDurationMs(0L) + .threshold(0.0) + .build() + ) + .build() + ) + .output( + RealtimeAudioConfigOutput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type(RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM) + .build() + ) + .speed(0.25) + .voice(RealtimeAudioConfigOutput.Voice.UnionMember1.ALLOY) + .build() + ) + .build() + ) + .addInclude( + RealtimeSessionCreateRequest.Include.ITEM_INPUT_AUDIO_TRANSCRIPTION_LOGPROBS + ) + .instructions("instructions") + .maxOutputTokensInf() + .model(RealtimeSessionCreateRequest.Model.GPT_REALTIME) + .addOutputModality(RealtimeSessionCreateRequest.OutputModality.TEXT) + .parallelToolCalls(true) + .prompt( + ResponsePrompt.builder() + .id("id") + .variables( + ResponsePrompt.Variables.builder() + .putAdditionalProperty("foo", JsonValue.from("string")) + .build() + ) + .version("version") + .build() + ) + .reasoning( + RealtimeReasoning.builder().effort(RealtimeReasoningEffort.MINIMAL).build() + ) + .toolChoice(ToolChoiceOptions.NONE) + .addTool( + RealtimeFunctionTool.builder() + .description("description") + .name("name") + .parameters(JsonValue.from(mapOf())) + .type(RealtimeFunctionTool.Type.FUNCTION) + .build() + ) + .tracingAuto() + .truncation(RealtimeTruncation.RealtimeTruncationStrategy.AUTO) + .build() + ) + .build() + } + + @Test + fun body() { + val params = + CallCreateParams.builder() + .sdp("sdp") + .session( + RealtimeSessionCreateRequest.builder() + .audio( + RealtimeAudioConfig.builder() + .input( + RealtimeAudioConfigInput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type(RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM) + .build() + ) + .noiseReduction( + RealtimeAudioConfigInput.NoiseReduction.builder() + .type(NoiseReductionType.NEAR_FIELD) + .build() + ) + .transcription( + AudioTranscription.builder() + .delay(AudioTranscription.Delay.MINIMAL) + .addKeyword("string") + .language("language") + .addLanguage("string") + .model(AudioTranscription.Model.WHISPER_1) + .prompt("prompt") + .build() + ) + .turnDetection( + RealtimeAudioInputTurnDetection.ServerVad.builder() + .createResponse(true) + .idleTimeoutMs(5000L) + .interruptResponse(true) + .prefixPaddingMs(0L) + .silenceDurationMs(0L) + .threshold(0.0) + .build() + ) + .build() + ) + .output( + RealtimeAudioConfigOutput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type(RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM) + .build() + ) + .speed(0.25) + .voice(RealtimeAudioConfigOutput.Voice.UnionMember1.ALLOY) + .build() + ) + .build() + ) + .addInclude( + RealtimeSessionCreateRequest.Include + .ITEM_INPUT_AUDIO_TRANSCRIPTION_LOGPROBS + ) + .instructions("instructions") + .maxOutputTokensInf() + .model(RealtimeSessionCreateRequest.Model.GPT_REALTIME) + .addOutputModality(RealtimeSessionCreateRequest.OutputModality.TEXT) + .parallelToolCalls(true) + .prompt( + ResponsePrompt.builder() + .id("id") + .variables( + ResponsePrompt.Variables.builder() + .putAdditionalProperty("foo", JsonValue.from("string")) + .build() + ) + .version("version") + .build() + ) + .reasoning( + RealtimeReasoning.builder() + .effort(RealtimeReasoningEffort.MINIMAL) + .build() + ) + .toolChoice(ToolChoiceOptions.NONE) + .addTool( + RealtimeFunctionTool.builder() + .description("description") + .name("name") + .parameters(JsonValue.from(mapOf())) + .type(RealtimeFunctionTool.Type.FUNCTION) + .build() + ) + .tracingAuto() + .truncation(RealtimeTruncation.RealtimeTruncationStrategy.AUTO) + .build() + ) + .build() + + val body = params._body() + + assertThat(body.filterValues { !it.value.isNull() && !it.value.isMissing() }) + .usingRecursiveComparison() + // TODO(AssertJ): Replace this and the `mapValues` below with: + // https://github.com/assertj/assertj/issues/3165 + .withEqualsForType( + { a, b -> a.readBytes() contentEquals b.readBytes() }, + InputStream::class.java, + ) + .isEqualTo( + mapOf( + "sdp" to MultipartField.of("sdp"), + "session" to + MultipartField.of( + RealtimeSessionCreateRequest.builder() + .audio( + RealtimeAudioConfig.builder() + .input( + RealtimeAudioConfigInput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate( + RealtimeAudioFormats.AudioPcm.Rate + ._24000 + ) + .type( + RealtimeAudioFormats.AudioPcm.Type + .AUDIO_PCM + ) + .build() + ) + .noiseReduction( + RealtimeAudioConfigInput.NoiseReduction + .builder() + .type(NoiseReductionType.NEAR_FIELD) + .build() + ) + .transcription( + AudioTranscription.builder() + .delay(AudioTranscription.Delay.MINIMAL) + .addKeyword("string") + .language("language") + .addLanguage("string") + .model( + AudioTranscription.Model.WHISPER_1 + ) + .prompt("prompt") + .build() + ) + .turnDetection( + RealtimeAudioInputTurnDetection.ServerVad + .builder() + .createResponse(true) + .idleTimeoutMs(5000L) + .interruptResponse(true) + .prefixPaddingMs(0L) + .silenceDurationMs(0L) + .threshold(0.0) + .build() + ) + .build() + ) + .output( + RealtimeAudioConfigOutput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate( + RealtimeAudioFormats.AudioPcm.Rate + ._24000 + ) + .type( + RealtimeAudioFormats.AudioPcm.Type + .AUDIO_PCM + ) + .build() + ) + .speed(0.25) + .voice( + RealtimeAudioConfigOutput.Voice.UnionMember1 + .ALLOY + ) + .build() + ) + .build() + ) + .addInclude( + RealtimeSessionCreateRequest.Include + .ITEM_INPUT_AUDIO_TRANSCRIPTION_LOGPROBS + ) + .instructions("instructions") + .maxOutputTokensInf() + .model(RealtimeSessionCreateRequest.Model.GPT_REALTIME) + .addOutputModality( + RealtimeSessionCreateRequest.OutputModality.TEXT + ) + .parallelToolCalls(true) + .prompt( + ResponsePrompt.builder() + .id("id") + .variables( + ResponsePrompt.Variables.builder() + .putAdditionalProperty( + "foo", + JsonValue.from("string"), + ) + .build() + ) + .version("version") + .build() + ) + .reasoning( + RealtimeReasoning.builder() + .effort(RealtimeReasoningEffort.MINIMAL) + .build() + ) + .toolChoice(ToolChoiceOptions.NONE) + .addTool( + RealtimeFunctionTool.builder() + .description("description") + .name("name") + .parameters(JsonValue.from(mapOf())) + .type(RealtimeFunctionTool.Type.FUNCTION) + .build() + ) + .tracingAuto() + .truncation(RealtimeTruncation.RealtimeTruncationStrategy.AUTO) + .build() + ), + ) + .mapValues { (_, field) -> + field.map { (it as? ByteArray)?.inputStream() ?: it } + } + ) + } + + @Test + fun bodyWithoutOptionalFields() { + val params = CallCreateParams.builder().sdp("sdp").build() + + val body = params._body() + + assertThat(body.filterValues { !it.value.isNull() && !it.value.isMissing() }) + .usingRecursiveComparison() + // TODO(AssertJ): Replace this and the `mapValues` below with: + // https://github.com/assertj/assertj/issues/3165 + .withEqualsForType( + { a, b -> a.readBytes() contentEquals b.readBytes() }, + InputStream::class.java, + ) + .isEqualTo( + mapOf("sdp" to MultipartField.of("sdp")).mapValues { (_, field) -> + field.map { (it as? ByteArray)?.inputStream() ?: it } + } + ) + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/services/async/realtime/CallServiceAsyncTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/async/realtime/CallServiceAsyncTest.kt index 94a460792..bcb4ab455 100644 --- a/openai-java-core/src/test/kotlin/com/openai/services/async/realtime/CallServiceAsyncTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/services/async/realtime/CallServiceAsyncTest.kt @@ -2,9 +2,18 @@ package com.openai.services.async.realtime +import com.github.tomakehurst.wiremock.client.WireMock.anyUrl +import com.github.tomakehurst.wiremock.client.WireMock.findAll +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.stubFor +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo +import com.github.tomakehurst.wiremock.junit5.WireMockTest import com.openai.TestServerExtension import com.openai.client.okhttp.OpenAIOkHttpClientAsync import com.openai.core.JsonValue +import com.openai.core.jsonMapper import com.openai.models.realtime.AudioTranscription import com.openai.models.realtime.NoiseReductionType import com.openai.models.realtime.RealtimeAudioConfig @@ -18,16 +27,173 @@ import com.openai.models.realtime.RealtimeReasoningEffort import com.openai.models.realtime.RealtimeSessionCreateRequest import com.openai.models.realtime.RealtimeTruncation import com.openai.models.realtime.calls.CallAcceptParams +import com.openai.models.realtime.calls.CallCreateParams import com.openai.models.realtime.calls.CallReferParams import com.openai.models.realtime.calls.CallRejectParams import com.openai.models.responses.ResponsePrompt import com.openai.models.responses.ToolChoiceOptions +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.parallel.ResourceLock @ExtendWith(TestServerExtension::class) +@WireMockTest +@ResourceLock("https://github.com/wiremock/wiremock/issues/169") internal class CallServiceAsyncTest { + @Test + fun create(wmRuntimeInfo: WireMockRuntimeInfo) { + // Work around Steady's JSON multipart decoding limitations with a local wire-contract mock. + val client = + OpenAIOkHttpClientAsync.builder() + .baseUrl(wmRuntimeInfo.httpBaseUrl) + .apiKey("My API Key") + .adminApiKey("My Admin API Key") + .build() + try { + val callServiceAsync = client.realtime().calls() + val params = + CallCreateParams.builder() + .sdp("sdp") + .session( + RealtimeSessionCreateRequest.builder() + .audio( + RealtimeAudioConfig.builder() + .input( + RealtimeAudioConfigInput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type( + RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM + ) + .build() + ) + .noiseReduction( + RealtimeAudioConfigInput.NoiseReduction.builder() + .type(NoiseReductionType.NEAR_FIELD) + .build() + ) + .transcription( + AudioTranscription.builder() + .delay(AudioTranscription.Delay.MINIMAL) + .addKeyword("string") + .language("language") + .addLanguage("string") + .model(AudioTranscription.Model.WHISPER_1) + .prompt("prompt") + .build() + ) + .turnDetection( + RealtimeAudioInputTurnDetection.ServerVad.builder() + .createResponse(true) + .idleTimeoutMs(5000L) + .interruptResponse(true) + .prefixPaddingMs(0L) + .silenceDurationMs(0L) + .threshold(0.0) + .build() + ) + .build() + ) + .output( + RealtimeAudioConfigOutput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type( + RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM + ) + .build() + ) + .speed(0.25) + .voice( + RealtimeAudioConfigOutput.Voice.UnionMember1.ALLOY + ) + .build() + ) + .build() + ) + .addInclude( + RealtimeSessionCreateRequest.Include + .ITEM_INPUT_AUDIO_TRANSCRIPTION_LOGPROBS + ) + .instructions("instructions") + .maxOutputTokensInf() + .model(RealtimeSessionCreateRequest.Model.GPT_REALTIME) + .addOutputModality(RealtimeSessionCreateRequest.OutputModality.TEXT) + .parallelToolCalls(true) + .prompt( + ResponsePrompt.builder() + .id("id") + .variables( + ResponsePrompt.Variables.builder() + .putAdditionalProperty("foo", JsonValue.from("string")) + .build() + ) + .version("version") + .build() + ) + .reasoning( + RealtimeReasoning.builder() + .effort(RealtimeReasoningEffort.MINIMAL) + .build() + ) + .toolChoice(ToolChoiceOptions.NONE) + .addTool( + RealtimeFunctionTool.builder() + .description("description") + .name("name") + .parameters(JsonValue.from(mapOf())) + .type(RealtimeFunctionTool.Type.FUNCTION) + .build() + ) + .tracingAuto() + .truncation(RealtimeTruncation.RealtimeTruncationStrategy.AUTO) + .build() + ) + .build() + stubFor(post(anyUrl()).willReturn(ok().withBody("abc"))) + + callServiceAsync.create(params).get().use { response -> + assertThat(response.body()).hasContent("abc") + } + + val requests = findAll(postRequestedFor(anyUrl())) + assertThat(requests).hasSize(1) + val request = requests.single() + assertThat(request.isMultipart).isTrue() + val encodings = + mapOf( + "sdp" to ("application/sdp" to false), + "session" to ("application/json" to true), + ) + for ((name, encoding) in encodings) { + val parts = request.parts.filter { it.name == name } + assertThat(request.parts.map { it.name }).noneMatch { it.startsWith("${name}[") } + val field = params._body()[name] + if (field == null || field.value.isMissing()) { + assertThat(parts).isEmpty() + continue + } + assertThat(parts).hasSize(1) + val part = parts.single() + assertThat(part.getHeader("Content-Type").firstValue()).isEqualTo(encoding.first) + assertThat(part.getHeader("Content-Disposition").firstValue()) + .doesNotContain("filename=") + val expected = jsonMapper().readTree(jsonMapper().writeValueAsString(field.value)) + if (encoding.second) { + assertThat(jsonMapper().readTree(part.body.asString())).isEqualTo(expected) + } else { + assertThat(part.body.asString()).isEqualTo(expected.textValue()) + } + } + } finally { + client.close() + } + } + @Test fun accept() { val client = diff --git a/openai-java-core/src/test/kotlin/com/openai/services/blocking/realtime/CallServiceTest.kt b/openai-java-core/src/test/kotlin/com/openai/services/blocking/realtime/CallServiceTest.kt index 4add89077..e6676aa29 100644 --- a/openai-java-core/src/test/kotlin/com/openai/services/blocking/realtime/CallServiceTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/services/blocking/realtime/CallServiceTest.kt @@ -2,9 +2,18 @@ package com.openai.services.blocking.realtime +import com.github.tomakehurst.wiremock.client.WireMock.anyUrl +import com.github.tomakehurst.wiremock.client.WireMock.findAll +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.stubFor +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo +import com.github.tomakehurst.wiremock.junit5.WireMockTest import com.openai.TestServerExtension import com.openai.client.okhttp.OpenAIOkHttpClient import com.openai.core.JsonValue +import com.openai.core.jsonMapper import com.openai.models.realtime.AudioTranscription import com.openai.models.realtime.NoiseReductionType import com.openai.models.realtime.RealtimeAudioConfig @@ -18,16 +27,173 @@ import com.openai.models.realtime.RealtimeReasoningEffort import com.openai.models.realtime.RealtimeSessionCreateRequest import com.openai.models.realtime.RealtimeTruncation import com.openai.models.realtime.calls.CallAcceptParams +import com.openai.models.realtime.calls.CallCreateParams import com.openai.models.realtime.calls.CallReferParams import com.openai.models.realtime.calls.CallRejectParams import com.openai.models.responses.ResponsePrompt import com.openai.models.responses.ToolChoiceOptions +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.parallel.ResourceLock @ExtendWith(TestServerExtension::class) +@WireMockTest +@ResourceLock("https://github.com/wiremock/wiremock/issues/169") internal class CallServiceTest { + @Test + fun create(wmRuntimeInfo: WireMockRuntimeInfo) { + // Work around Steady's JSON multipart decoding limitations with a local wire-contract mock. + val client = + OpenAIOkHttpClient.builder() + .baseUrl(wmRuntimeInfo.httpBaseUrl) + .apiKey("My API Key") + .adminApiKey("My Admin API Key") + .build() + try { + val callService = client.realtime().calls() + val params = + CallCreateParams.builder() + .sdp("sdp") + .session( + RealtimeSessionCreateRequest.builder() + .audio( + RealtimeAudioConfig.builder() + .input( + RealtimeAudioConfigInput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type( + RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM + ) + .build() + ) + .noiseReduction( + RealtimeAudioConfigInput.NoiseReduction.builder() + .type(NoiseReductionType.NEAR_FIELD) + .build() + ) + .transcription( + AudioTranscription.builder() + .delay(AudioTranscription.Delay.MINIMAL) + .addKeyword("string") + .language("language") + .addLanguage("string") + .model(AudioTranscription.Model.WHISPER_1) + .prompt("prompt") + .build() + ) + .turnDetection( + RealtimeAudioInputTurnDetection.ServerVad.builder() + .createResponse(true) + .idleTimeoutMs(5000L) + .interruptResponse(true) + .prefixPaddingMs(0L) + .silenceDurationMs(0L) + .threshold(0.0) + .build() + ) + .build() + ) + .output( + RealtimeAudioConfigOutput.builder() + .format( + RealtimeAudioFormats.AudioPcm.builder() + .rate(RealtimeAudioFormats.AudioPcm.Rate._24000) + .type( + RealtimeAudioFormats.AudioPcm.Type.AUDIO_PCM + ) + .build() + ) + .speed(0.25) + .voice( + RealtimeAudioConfigOutput.Voice.UnionMember1.ALLOY + ) + .build() + ) + .build() + ) + .addInclude( + RealtimeSessionCreateRequest.Include + .ITEM_INPUT_AUDIO_TRANSCRIPTION_LOGPROBS + ) + .instructions("instructions") + .maxOutputTokensInf() + .model(RealtimeSessionCreateRequest.Model.GPT_REALTIME) + .addOutputModality(RealtimeSessionCreateRequest.OutputModality.TEXT) + .parallelToolCalls(true) + .prompt( + ResponsePrompt.builder() + .id("id") + .variables( + ResponsePrompt.Variables.builder() + .putAdditionalProperty("foo", JsonValue.from("string")) + .build() + ) + .version("version") + .build() + ) + .reasoning( + RealtimeReasoning.builder() + .effort(RealtimeReasoningEffort.MINIMAL) + .build() + ) + .toolChoice(ToolChoiceOptions.NONE) + .addTool( + RealtimeFunctionTool.builder() + .description("description") + .name("name") + .parameters(JsonValue.from(mapOf())) + .type(RealtimeFunctionTool.Type.FUNCTION) + .build() + ) + .tracingAuto() + .truncation(RealtimeTruncation.RealtimeTruncationStrategy.AUTO) + .build() + ) + .build() + stubFor(post(anyUrl()).willReturn(ok().withBody("abc"))) + + callService.create(params).use { response -> + assertThat(response.body()).hasContent("abc") + } + + val requests = findAll(postRequestedFor(anyUrl())) + assertThat(requests).hasSize(1) + val request = requests.single() + assertThat(request.isMultipart).isTrue() + val encodings = + mapOf( + "sdp" to ("application/sdp" to false), + "session" to ("application/json" to true), + ) + for ((name, encoding) in encodings) { + val parts = request.parts.filter { it.name == name } + assertThat(request.parts.map { it.name }).noneMatch { it.startsWith("${name}[") } + val field = params._body()[name] + if (field == null || field.value.isMissing()) { + assertThat(parts).isEmpty() + continue + } + assertThat(parts).hasSize(1) + val part = parts.single() + assertThat(part.getHeader("Content-Type").firstValue()).isEqualTo(encoding.first) + assertThat(part.getHeader("Content-Disposition").firstValue()) + .doesNotContain("filename=") + val expected = jsonMapper().readTree(jsonMapper().writeValueAsString(field.value)) + if (encoding.second) { + assertThat(jsonMapper().readTree(part.body.asString())).isEqualTo(expected) + } else { + assertThat(part.body.asString()).isEqualTo(expected.textValue()) + } + } + } finally { + client.close() + } + } + @Test fun accept() { val client =