From e96e24843c101df467a3ba2d72790d702c484b2c Mon Sep 17 00:00:00 2001 From: Junhui Li Date: Wed, 24 Jun 2026 11:03:16 -0700 Subject: [PATCH] Fix OCI signing for multipart audio requests --- .../genai/auth/OciSigningInterceptor.java | 37 ++++--- .../genai/auth/OciSigningInterceptorTest.java | 33 +++++++ .../OpenAITranscriptionIntegrationTest.java | 99 +++++++++++++++++++ 3 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAITranscriptionIntegrationTest.java diff --git a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciSigningInterceptor.java b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciSigningInterceptor.java index 78328b6..9405281 100644 --- a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciSigningInterceptor.java +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciSigningInterceptor.java @@ -64,27 +64,32 @@ public Response intercept(Chain chain) throws IOException { URI uri = originalRequest.url().uri(); String method = originalRequest.method(); - // Build the headers map that OCI signing expects. - // Normalize content-type to strip "; charset=utf-8" that OkHttp appends - // to string bodies — some OCI endpoints strip it before signature verification, - // causing SIGNATURE_NOT_VALID errors. + // Read the request body for signing (OCI signs the body digest) + byte[] bodyBytes = null; + if (originalRequest.body() != null) { + Buffer buffer = new Buffer(); + originalRequest.body().writeTo(buffer); + bodyBytes = buffer.readByteArray(); + } + + // Build the headers map that OCI signing expects. OkHttp adds body-derived + // Content-Type and Content-Length later, so provide the exact values here. Map> existingHeaders = new HashMap<>(); for (String name : originalRequest.headers().names()) { List values = originalRequest.headers(name); if ("content-type".equalsIgnoreCase(name)) { values = values.stream() - .map(v -> v.replaceAll(";\\s*charset=utf-8", "").trim()) + .map(OciSigningInterceptor::normalizeContentType) .toList(); } existingHeaders.put(name, values); } - - // Read the request body for signing (OCI signs the body digest) - byte[] bodyBytes = null; - if (originalRequest.body() != null) { - Buffer buffer = new Buffer(); - originalRequest.body().writeTo(buffer); - bodyBytes = buffer.readByteArray(); + if (bodyBytes != null) { + if (!containsHeader(existingHeaders, "content-type") && originalRequest.body().contentType() != null) { + existingHeaders.put("content-type", + List.of(normalizeContentType(originalRequest.body().contentType().toString()))); + } + existingHeaders.put("content-length", List.of(String.valueOf(bodyBytes.length))); } // Compute OCI signature headers @@ -123,6 +128,14 @@ public Response intercept(Chain chain) throws IOException { return chain.proceed(signedRequest); } + private static boolean containsHeader(Map> headers, String headerName) { + return headers.keySet().stream().anyMatch(name -> name.equalsIgnoreCase(headerName)); + } + + private static String normalizeContentType(String contentType) { + return contentType.replaceAll(";\\s*charset=utf-8", "").trim(); + } + /** * A {@link ByteArrayInputStream} that implements {@link DuplicatableInputStream}, * required by OCI SDK 3.x {@code RequestSignerImpl} for body signing. diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciSigningInterceptorTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciSigningInterceptorTest.java index 9cbee50..c86c425 100644 --- a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciSigningInterceptorTest.java +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciSigningInterceptorTest.java @@ -7,6 +7,7 @@ import com.oracle.bmc.auth.BasicAuthenticationDetailsProvider; import okhttp3.MediaType; +import okhttp3.MultipartBody; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; @@ -111,6 +112,38 @@ void signsPostBodyWithContentHeaders() throws Exception { "Body should be preserved after signing"); } + @Test + void signsMultipartBodyWithWireContentHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + RequestBody audio = RequestBody.create( + new byte[] {0x52, 0x49, 0x46, 0x46}, MediaType.parse("audio/wav")); + RequestBody body = new MultipartBody.Builder("test-boundary") + .setType(MultipartBody.FORM) + .addFormDataPart("model", "vllm-model") + .addFormDataPart("file", "sample.wav", audio) + .build(); + + client.newCall(new Request.Builder() + .url(server.url("/audio/transcriptions")) + .post(body) + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + String authorization = request.getHeader("Authorization"); + assertNotNull(authorization, "Authorization should be present"); + assertTrue(authorization.contains("content-length"), + "content-length should be part of the signed header list"); + assertTrue(authorization.contains("content-type"), + "content-type should be part of the signed header list"); + assertTrue(authorization.contains("x-content-sha256"), + "x-content-sha256 should be part of the signed header list"); + assertEquals("multipart/form-data; boundary=test-boundary", request.getHeader("Content-Type")); + assertEquals(String.valueOf(request.getBodySize()), request.getHeader("Content-Length")); + assertTrue(request.getBody().readUtf8().contains("sample.wav"), + "Multipart body should be preserved after signing"); + } + @Test void preservesOriginalHeaders() throws Exception { server.enqueue(new MockResponse().setResponseCode(200)); diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAITranscriptionIntegrationTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAITranscriptionIntegrationTest.java new file mode 100644 index 0000000..7195efa --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAITranscriptionIntegrationTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v 1.0 as shown at + * https://oss.oracle.com/licenses/upl/ + */ +package com.oracle.genai.auth; + +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientImpl; +import com.openai.core.ClientOptions; +import com.openai.models.audio.transcriptions.TranscriptionCreateParams; +import okhttp3.OkHttpClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import javax.sound.sampled.AudioFileFormat; +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Live transcription test for OCI IAM signing with multipart audio requests. + * + *

To run: + *

+ * oci session authenticate --region eu-frankfurt-1 --profile-name BoatOc1
+ * mvn -pl oci-genai-auth-java-core test -Dtest=OpenAITranscriptionIntegrationTest -DliveTranscription=true \
+ *   -Doci.genai.transcription.baseUrl=https://inference.generativeai.eu-frankfurt-1.oci.oraclecloud.com/openai/v1 \
+ *   -Doci.genai.transcription.model= \
+ *   -Doci.genai.transcription.compartmentId=
+ * 
+ */ +class OpenAITranscriptionIntegrationTest { + + private static final String BASE_URL = System.getProperty("oci.genai.transcription.baseUrl", + "https://inference.generativeai.eu-frankfurt-1.oci.oraclecloud.com/20231130/actions/v1"); + private static final String PROFILE = System.getProperty("oci.genai.transcription.profile", "BoatOc1"); + private static final String MODEL = System.getProperty("oci.genai.transcription.model", "vllm-model"); + private static final String COMPARTMENT_ID = System.getProperty("oci.genai.transcription.compartmentId", ""); + + @Test + @EnabledIfSystemProperty(named = "liveTranscription", matches = "true") + void transcription_api_via_oci_auth_library() throws Exception { + Path audio = Files.createTempFile("oci-transcription-integration-", ".wav"); + writeWav(audio); + + OciAuthConfig.Builder configBuilder = OciAuthConfig.builder() + .authType("security_token") + .profile(PROFILE); + if (!COMPARTMENT_ID.isBlank()) { + configBuilder.compartmentId(COMPARTMENT_ID); + } + OciAuthConfig config = configBuilder.build(); + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + OpenAIClient client = new OpenAIClientImpl(ClientOptions.builder() + .httpClient(OciOpenAIHttpClient.of(ociHttpClient, BASE_URL)) + .baseUrl(BASE_URL) + .apiKey("OCI_AUTH") + .build()); + + try { + Object response = client.audio().transcriptions().create( + TranscriptionCreateParams.builder() + .file(audio) + .model(MODEL) + .language("en") + .build()); + + System.out.println("Transcription response: " + response); + assertNotNull(response, "Transcription response should not be null"); + } finally { + client.close(); + Files.deleteIfExists(audio); + } + } + + private static void writeWav(Path path) throws Exception { + int sampleRate = 16_000; + int durationSeconds = 2; + byte[] pcm = new byte[sampleRate * durationSeconds * 2]; + for (int i = 0; i < sampleRate * durationSeconds; i++) { + double angle = 2.0 * Math.PI * 440.0 * i / sampleRate; + short sample = (short) (Math.sin(angle) * Short.MAX_VALUE * 0.25); + pcm[i * 2] = (byte) (sample & 0xff); + pcm[i * 2 + 1] = (byte) ((sample >>> 8) & 0xff); + } + + AudioFormat format = new AudioFormat(sampleRate, 16, 1, true, false); + try (AudioInputStream stream = new AudioInputStream( + new ByteArrayInputStream(pcm), format, sampleRate * durationSeconds)) { + AudioSystem.write(stream, AudioFileFormat.Type.WAVE, path.toFile()); + } + } +}