diff --git a/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponse.kt b/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponse.kt index a935a9a7d..30ce95c27 100644 --- a/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponse.kt +++ b/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponse.kt @@ -258,12 +258,12 @@ private constructor( tryDeserialize(node, jacksonTypeRef())?.let { TranscriptionCreateResponse(transcription = it, _json = json) }, - tryDeserialize(node, jacksonTypeRef())?.let { - TranscriptionCreateResponse(diarized = it, _json = json) - }, tryDeserialize(node, jacksonTypeRef())?.let { TranscriptionCreateResponse(verbose = it, _json = json) }, + tryDeserialize(node, jacksonTypeRef())?.let { + TranscriptionCreateResponse(diarized = it, _json = json) + }, ) .filterNotNull() .allMaxBy { it.validity() } diff --git a/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarized.kt b/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarized.kt index f8a368ef3..690e88837 100644 --- a/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarized.kt +++ b/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarized.kt @@ -58,11 +58,22 @@ private constructor( /** * Duration of the input audio in seconds. * + * This accessor preserves the existing behavior and throws when the field is missing or null. + * Use [durationOptional] when the server may omit the field. + * * @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 duration(): Double = duration.getRequired("duration") + /** + * Returns the duration of the input audio when provided by the server. + * + * @throws OpenAIInvalidDataException if the JSON field has an unexpected type (e.g. if the + * server responded with an unexpected value). + */ + fun durationOptional(): Optional = duration.getOptional("duration") + /** * Segments of the transcript annotated with timestamps and speaker labels. * @@ -149,7 +160,6 @@ private constructor( * * The following fields are required: * ```java - * .duration() * .segments() * .text() * ``` @@ -160,7 +170,7 @@ private constructor( /** A builder for [TranscriptionDiarized]. */ class Builder internal constructor() { - private var duration: JsonField? = null + private var duration: JsonField = JsonMissing.of() private var segments: JsonField>? = null private var task: JsonValue = JsonValue.from("transcribe") private var text: JsonField? = null @@ -178,7 +188,17 @@ private constructor( } /** Duration of the input audio in seconds. */ - fun duration(duration: Double) = duration(JsonField.of(duration)) + fun duration(duration: Double?) = duration(JsonField.ofNullable(duration)) + + /** + * Alias for [Builder.duration]. + * + * This unboxed primitive overload exists for backwards compatibility. + */ + fun duration(duration: Double) = duration(duration as Double?) + + /** Alias for calling [Builder.duration] with `duration.orElse(null)`. */ + fun duration(duration: Optional) = duration(duration.getOrNull()) /** * Sets [Builder.duration] to an arbitrary JSON value. @@ -294,7 +314,6 @@ private constructor( * * The following fields are required: * ```java - * .duration() * .segments() * .text() * ``` @@ -303,7 +322,7 @@ private constructor( */ fun build(): TranscriptionDiarized = TranscriptionDiarized( - checkRequired("duration", duration), + duration, checkRequired("segments", segments).map { it.toImmutable() }, task, checkRequired("text", text), @@ -327,10 +346,11 @@ private constructor( return@apply } - duration() + durationOptional() segments().forEach { it.validate() } + // The API may omit task or return null; when present, it must be "transcribe". _task().let { - if (it != JsonValue.from("transcribe")) { + if (!it.isMissing() && !it.isNull() && it != JsonValue.from("transcribe")) { throw OpenAIInvalidDataException("'task' is invalid, received $it") } } @@ -355,6 +375,7 @@ private constructor( @JvmSynthetic internal fun validity(): Int = (if (duration.asKnown().isPresent) 1 else 0) + + (if (segments.asKnown().isPresent) 1 else 0) + (segments.asKnown().getOrNull()?.sumOf { it.validity().toInt() } ?: 0) + task.let { if (it == JsonValue.from("transcribe")) 1 else 0 } + (if (text.asKnown().isPresent) 1 else 0) + diff --git a/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionVerbose.kt b/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionVerbose.kt index 46980eaf9..18224dcf9 100644 --- a/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionVerbose.kt +++ b/openai-java-core/src/main/kotlin/com/openai/models/audio/transcriptions/TranscriptionVerbose.kt @@ -370,6 +370,7 @@ private constructor( (if (duration.asKnown().isPresent) 1 else 0) + (if (language.asKnown().isPresent) 1 else 0) + (if (text.asKnown().isPresent) 1 else 0) + + (if (segments.asKnown().isPresent) 1 else 0) + (segments.asKnown().getOrNull()?.sumOf { it.validity().toInt() } ?: 0) + (usage.asKnown().getOrNull()?.validity() ?: 0) + (words.asKnown().getOrNull()?.sumOf { it.validity().toInt() } ?: 0) diff --git a/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponseTest.kt b/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponseTest.kt index f45fdb3d5..c2a937fa5 100644 --- a/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponseTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionCreateResponseTest.kt @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource internal class TranscriptionCreateResponseTest { @@ -164,6 +165,63 @@ internal class TranscriptionCreateResponseTest { assertThat(roundtrippedTranscriptionCreateResponse).isEqualTo(transcriptionCreateResponse) } + @ParameterizedTest + @ValueSource(strings = ["", "\"task\": null,", "\"task\": \"transcribe\","]) + fun deserializeDiarizedWithEmptySegments(task: String) { + val transcriptionCreateResponse = + jsonMapper() + .readValue( + """ + { + "text": "", + $task + "segments": [] + } + """ + .trimIndent(), + jacksonTypeRef(), + ) + + assertThat(transcriptionCreateResponse.isDiarized()).isTrue() + } + + @Test + fun deserializePlainTranscriptionWithoutDiarizedFields() { + val transcriptionCreateResponse = + jsonMapper() + .readValue( + """ + { + "text": "hello" + } + """ + .trimIndent(), + jacksonTypeRef(), + ) + + assertThat(transcriptionCreateResponse.isTranscription()).isTrue() + } + + @Test + fun deserializeVerboseWithEmptySegments() { + val transcriptionCreateResponse = + jsonMapper() + .readValue( + """ + { + "duration": 0.0, + "language": "en", + "text": "", + "segments": [] + } + """ + .trimIndent(), + jacksonTypeRef(), + ) + + assertThat(transcriptionCreateResponse.isVerbose()).isTrue() + } + @Test fun ofVerbose() { val verbose = diff --git a/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarizedTest.kt b/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarizedTest.kt index 0d0dfd851..c02e2179b 100644 --- a/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarizedTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/models/audio/transcriptions/TranscriptionDiarizedTest.kt @@ -3,17 +3,23 @@ package com.openai.models.audio.transcriptions import com.fasterxml.jackson.module.kotlin.jacksonTypeRef +import com.openai.core.JsonValue import com.openai.core.jsonMapper import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test internal class TranscriptionDiarizedTest { + @Test + fun durationGetterKeepsItsBinaryCompatibleReturnType() { + assertThat(TranscriptionDiarized::class.java.getMethod("duration").returnType) + .isEqualTo(Double::class.javaPrimitiveType) + } + @Test fun create() { val transcriptionDiarized = TranscriptionDiarized.builder() - .duration(0.0) .addSegment( TranscriptionDiarizedSegment.builder() .id("id") @@ -39,7 +45,7 @@ internal class TranscriptionDiarizedTest { ) .build() - assertThat(transcriptionDiarized.duration()).isEqualTo(0.0) + assertThat(transcriptionDiarized.durationOptional()).isEmpty assertThat(transcriptionDiarized.segments()) .containsExactly( TranscriptionDiarizedSegment.builder() @@ -69,6 +75,112 @@ internal class TranscriptionDiarizedTest { ) } + @Test + fun deserializeWithoutOptionalFields() { + val transcriptionDiarized = + jsonMapper() + .readValue( + """ + { + "text": "text", + "segments": [ + { + "type": "transcript.text.segment", + "id": "id", + "end": 1.0, + "speaker": "speaker", + "start": 0.0, + "text": "text" + } + ] + } + """ + .trimIndent(), + jacksonTypeRef(), + ) + + assertThat(transcriptionDiarized.durationOptional()).isEmpty + assertThat(transcriptionDiarized.isValid()).isTrue() + } + + @Test + fun rejectsInvalidDuration() { + val transcriptionDiarized = + jsonMapper() + .readValue( + """ + { + "duration": "invalid", + "text": "text", + "segments": [ + { + "type": "transcript.text.segment", + "id": "id", + "end": 1.0, + "speaker": "speaker", + "start": 0.0, + "text": "text" + } + ] + } + """ + .trimIndent(), + jacksonTypeRef(), + ) + + assertThat(transcriptionDiarized.isValid()).isFalse() + } + + @Test + fun rejectsUnexpectedTask() { + val transcriptionDiarized = + TranscriptionDiarized.builder() + .addSegment( + TranscriptionDiarizedSegment.builder() + .id("id") + .end(1.0) + .speaker("speaker") + .start(0.0) + .text("text") + .build() + ) + .task(JsonValue.from("translate")) + .text("text") + .build() + + assertThat(transcriptionDiarized.isValid()).isFalse() + } + + @Test + fun acceptsMissingAndNullTask() { + val json = + """ + { + "text": "text", + %s + "segments": [] + } + """ + .trimIndent() + + val missingTask = + jsonMapper().readValue(json.format(""), jacksonTypeRef()) + val nullTask = + jsonMapper() + .readValue(json.format("\"task\": null,"), jacksonTypeRef()) + val explicitTask = + jsonMapper() + .readValue( + json.format("\"task\": \"transcribe\","), + jacksonTypeRef(), + ) + + assertThat(missingTask.isValid()).isTrue() + assertThat(nullTask.isValid()).isTrue() + assertThat(missingTask.validity()).isEqualTo(nullTask.validity()) + assertThat(explicitTask.validity()).isEqualTo(missingTask.validity() + 1) + } + @Test fun roundtrip() { val jsonMapper = jsonMapper() @@ -107,5 +219,7 @@ internal class TranscriptionDiarizedTest { ) assertThat(roundtrippedTranscriptionDiarized).isEqualTo(transcriptionDiarized) + assertThat(roundtrippedTranscriptionDiarized.duration()).isEqualTo(0.0) + assertThat(roundtrippedTranscriptionDiarized.durationOptional()).contains(0.0) } }