diff --git a/.github/workflows/run-all-check.yaml b/.github/workflows/run-all-check.yaml new file mode 100644 index 0000000..c2aa986 --- /dev/null +++ b/.github/workflows/run-all-check.yaml @@ -0,0 +1,33 @@ +# This workflow will install Java dependencies, run tests and lint +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven + +name: Unit test and build check + +on: + workflow_dispatch: + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java-version: ["17", "21"] + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + cache: maven + - name: Build and test + run: mvn clean verify --batch-mode --no-transfer-progress + - name: Verify no vendor SDK in compile scope + run: | + mvn dependency:tree -pl oci-genai-auth-java-core --batch-mode --no-transfer-progress | \ + grep -v "test" | grep -v "INFO" | grep -v "WARNING" | \ + (! grep -E "anthropic|openai|google-cloud") diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..584488e --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Mac +.DS_Store + +# Build output +target/ + +# IDE files +.idea/ +*.iml +.project +.classpath +.settings/ +.vscode/ +*.swp +*.swo +*.orig + +# OS files +Thumbs.db + +# Maven +dependency-reduced-pom.xml + +# Logs +*.log + +# Environments +.env +.envrc + +# Claude Code +CLAUDE.md +.claude/ + +# Scratch files +scratch/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85ab22a..637430b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,3 @@ -*Detailed instructions on how to contribute to the project, if applicable. Must include section about Oracle Contributor Agreement with link and instructions* - # Contributing to this repository We welcome your contributions! There are multiple ways to contribute. diff --git a/LICENSE.txt b/LICENSE.txt index bb91ea7..92e1920 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2026 Oracle and/or its affiliates. +Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved. The Universal Permissive License (UPL), Version 1.0 diff --git a/README.md b/README.md index 73e8102..7228551 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,206 @@ -*This repository acts as a template for all of Oracle’s GitHub repositories. It contains information about the guidelines for those repositories. All files and sections contained in this template are mandatory, and a GitHub app ensures alignment with these guidelines. To get started with a new repository, replace the italic paragraphs with the respective text for your project.* +# oci-genai-auth-java -# Project name +The **OCI GenAI Auth** Java library provides OCI request-signing helpers for the OpenAI-compatible REST APIs hosted by OCI Generative AI. -*Describe your project's features, functionality and target audience* +## Table of Contents + +- [Before you start](#before-you-start) +- [Using OCI IAM Auth](#using-oci-iam-auth) +- [Using API Key Auth](#using-api-key-auth) +- [Using AgentHub APIs](#using-agenthub-apis) +- [Using Partner APIs](#using-partner-apis) +- [Running the Examples](#running-the-examples) +- [Building from Source](#building-from-source) + +## Before you start + +**Important!** + +Note that this package, as well as API keys described below, only supports OpenAI, xAi Grok and Meta LLama models on OCI Generative AI. + +Before you start using this package, determine if this is the right option for you. + +If you are looking for a seamless way to port your code from an OpenAI compatible endpoint to OCI Generative AI endpoint, and you are currently using OpenAI-style API keys, you might want to use [OCI Generative AI API Keys](https://docs.oracle.com/en-us/iaas/Content/generative-ai/api-keys.htm) instead. + +With OCI Generative AI API Keys, use the native `openai-java` SDK like before. Just update the `base_url`, create API keys in your OCI console, ensure the policy granting the key access to generative AI services is present and you are good to go. + +- Create an API key in Console: **Generative AI** -> **API Keys** +- Create a security policy: **Identity & Security** -> **Policies** + +To authorize a specific API Key +``` +allow any-user to use generative-ai-family in compartment where ALL { request.principal.type='generativeaiapikey', request.principal.id='ocid1.generativeaiapikey.oc1.us-chicago-1....' } +``` + +To authorize any API Key +``` +allow any-user to use generative-ai-family in compartment where ALL { request.principal.type='generativeaiapikey' } +``` ## Installation -*Provide detailed step-by-step installation instructions. You can name this section **How to Run** or **Getting Started** instead of **Installation** if that's more acceptable for your project* +Requires **Java 17+** and **Maven 3.8+**. + +```xml + + + + com.oracle.genai + oci-genai-auth-java-bom + 0.1.0-SNAPSHOT + pom + import + + + + + + + com.oracle.genai + oci-genai-auth-java-core + + +``` + +## Using OCI IAM Auth + +Use OCI IAM auth when you want to sign requests with your OCI profile (session/user/resource/instance principal) instead of API keys. + +```java +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientImpl; +import com.openai.core.ClientOptions; + +OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .compartmentId("ocid1.compartment.oc1..aaaaaaaaexample") + .build(); + +OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + +// Plug the OCI-signed OkHttpClient into the OpenAI SDK +OpenAIClient client = new OpenAIClientImpl( + ClientOptions.builder() + .baseUrl("https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/v1") + .apiKey("not-used") + .httpClient(new OpenAIOkHttpAdapter(ociHttpClient, baseUrl)) + .build()); +``` + +## Using API Key Auth + +Use OCI Generative AI API Keys if you want a direct API-key workflow with the OpenAI SDK. + +```java +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientImpl; +import com.openai.core.ClientOptions; + +OpenAIClient client = new OpenAIClientImpl( + ClientOptions.builder() + .baseUrl("https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1") + .apiKey(System.getenv("OCI_GENAI_API_KEY")) + .build()); +``` + +## Using AgentHub APIs + +AgentHub provides a unified interface for interacting with models and agentic capabilities. It is compatible with OpenAI's Responses API and the Open Responses Spec, enabling developers to build agents with the OpenAI SDK. Only the project OCID is required — no compartment ID needed. + +```java +OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .build(); + +OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + +OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl("https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1") + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .addHeader("openai-project", "ocid1.generativeaiproject.oc1.us-chicago-1.aaaaaaaaexample") + .build(); +``` + +## Using Partner APIs + +Partner endpoints require the compartment OCID header. + +```java +OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .compartmentId("ocid1.compartment.oc1..aaaaaaaaexample") + .build(); + +OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + +// The compartment ID is automatically injected as a header by the library +OpenAIClient client = new OpenAIClientImpl( + ClientOptions.builder() + .baseUrl("https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/v1") + .apiKey("not-used") + .httpClient(new OpenAIOkHttpAdapter(ociHttpClient, baseUrl)) + .build()); +``` + +## Authentication Types -## Documentation +| Auth Type | Use Case | +|-----------|----------| +| `oci_config` | Local development with API key in `~/.oci/config` | +| `security_token` | Local development with OCI CLI session token | +| `instance_principal` | OCI Compute instances with dynamic group policies | +| `resource_principal` | OCI Functions, Container Instances | -*Developer-oriented documentation can be published on GitHub, but all product documentation must be published on * +## Running the Examples -## Examples +1. Update the constants in each example with your `COMPARTMENT_ID`, `PROJECT_OCID`, and set the correct `REGION`. +2. Set the `OCI_GENAI_API_KEY` environment variable when an example uses API key authentication. +3. Install dependencies: `mvn install -DskipTests`. -*Describe any included examples or provide a link to a demo/tutorial* +The [examples/](examples/) directory is organized as follows: -## Help +| Directory | Description | +|-----------|-------------| +| [examples/agenthub/openai/](examples/agenthub/openai/) | AgentHub examples using the OpenAI Responses API | +| [examples/partner/openai/](examples/partner/openai/) | Partner examples using OpenAI Chat Completions | -*Inform users on where to get help or how to receive official support from Oracle (if applicable)* +These examples are **not** compiled as part of the Maven build. Copy them into your own project. -## Contributing +## Building from Source + +```bash +# Compile +mvn clean compile + +# Run tests +mvn test -*If your project has specific contribution requirements, update the CONTRIBUTING.md file to ensure those requirements are clearly explained* +# Full verification +mvn clean verify -This project welcomes contributions from the community. Before submitting a pull request, please [review our contribution guide](./CONTRIBUTING.md) +# Install to local Maven repository +mvn install -DskipTests + +# Confirm no vendor SDK dependencies +mvn dependency:tree -pl oci-genai-auth-java-core +``` + +## Contributing + +This project welcomes contributions from the community. Before submitting a pull request, please review our [contribution guide](./CONTRIBUTING.md). ## Security -Please consult the [security guide](./SECURITY.md) for our responsible security vulnerability disclosure process +Please consult the [security guide](./SECURITY.md) for our responsible security vulnerability disclosure process. ## License -*The correct copyright notice format for both documentation and software is* - "Copyright (c) [year,] year Oracle and/or its affiliates." -*You must include the year the content was first released (on any platform) and the most recent year in which it was revised* - Copyright (c) 2026 Oracle and/or its affiliates. -*Replace this statement if your project is not licensed under the UPL* - -Released under the Universal Permissive License v1.0 as shown at -. +Released under the [Universal Permissive License v1.0](https://oss.oracle.com/licenses/upl/). diff --git a/THIRD_PARTY_LICENSES.txt b/THIRD_PARTY_LICENSES.txt new file mode 100644 index 0000000..195c1ee --- /dev/null +++ b/THIRD_PARTY_LICENSES.txt @@ -0,0 +1,241 @@ +THIRD-PARTY SOFTWARE LICENSES + +The following third-party software is used as direct dependencies by this +project. Only compile-scope (runtime) dependencies are listed below. Test-scope +dependencies (JUnit, Mockito, MockWebServer, etc.) are not redistributed. + +================================================================================ + +1. OCI Java SDK Common + Group: com.oracle.oci.sdk + Artifact: oci-java-sdk-common + Version: 3.72.1 + License: Universal Permissive License v 1.0 (UPL-1.0) OR Apache License 2.0 + URL: https://github.com/oracle/oci-java-sdk + +2. OkHttp + Group: com.squareup.okhttp3 + Artifact: okhttp + Version: 5.3.2 + License: Apache License 2.0 + URL: https://github.com/square/okhttp + +3. OkHttp Logging Interceptor + Group: com.squareup.okhttp3 + Artifact: logging-interceptor + Version: 5.3.2 + License: Apache License 2.0 + URL: https://github.com/square/okhttp + +4. SLF4J API + Group: org.slf4j + Artifact: slf4j-api + Version: 2.0.17 + License: MIT License + URL: https://www.slf4j.org + +================================================================================ + +APACHE LICENSE 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +================================================================================ + +MIT LICENSE + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ diff --git a/examples/agenthub/README.md b/examples/agenthub/README.md new file mode 100644 index 0000000..701e3b1 --- /dev/null +++ b/examples/agenthub/README.md @@ -0,0 +1,35 @@ +# AgentHub Examples + +AgentHub provides a unified interface for interacting with models and agentic capabilities. +It is compatible with OpenAI's Responses API and the Open Responses Spec, enabling +developers to build agents with the OpenAI SDK. + +## Prerequisites + +1. Create a **Generative AI Project** on OCI Console. +2. Update `PROJECT_OCID` and `REGION` in each example file. +3. Authenticate with OCI: + - **OCI IAM**: `oci session authenticate` + - **API Key**: Set `OCI_GENAI_API_KEY` environment variable + +## Base URL + +``` +https://inference.generativeai..oci.oraclecloud.com/openai/v1 +``` + +## Examples + +| File | Description | +|------|-------------| +| `openai/QuickstartResponsesOciIam.java` | Quickstart with OCI IAM authentication | +| `openai/QuickstartResponsesApiKey.java` | Quickstart with API key authentication | +| `openai/responses/CreateResponse.java` | Create a response | +| `openai/responses/StreamingTextDelta.java` | Stream response text deltas | +| `openai/tools/WebSearch.java` | Web search tool | +| `openai/tools/FunctionCalling.java` | Function calling tool | + +## Running + +These are standalone Java files. Copy them into your project with the required dependencies +(`oci-genai-auth-java-core` and `openai-java`), then compile and run. diff --git a/examples/agenthub/openai/QuickstartResponsesApiKey.java b/examples/agenthub/openai/QuickstartResponsesApiKey.java new file mode 100644 index 0000000..283df65 --- /dev/null +++ b/examples/agenthub/openai/QuickstartResponsesApiKey.java @@ -0,0 +1,48 @@ +/* + * 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/ + */ + +/** + * Quickstart using Generative AI API Key authentication with the Responses API. + * + *

No oci-genai-auth package needed for API Key auth — just the official OpenAI SDK. + * + *

Steps: + *

    + *
  1. Create a Generative AI Project on OCI Console
  2. + *
  3. Create a Generative AI API Key on OCI Console
  4. + *
  5. Set OCI_GENAI_API_KEY environment variable
  6. + *
  7. Run this example
  8. + *
+ */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; + +public class QuickstartResponsesApiKey { + + // ── Configuration ────────────────────────────────────────────────── + private static final String PROJECT_OCID = "<>"; + // ──────────────────────────────────────────────────────────────────── + + public static void main(String[] args) { + // AgentHub only needs project OCID — no compartment ID required + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl("https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1") + .apiKey(System.getenv("OCI_GENAI_API_KEY")) + .addHeader("openai-project", PROJECT_OCID) + .build(); + + Response response = client.responses().create( + ResponseCreateParams.builder() + .model("xai.grok-3") + .input("What is 2x2?") + .build()); + + System.out.println(response.outputText()); + } +} diff --git a/examples/agenthub/openai/QuickstartResponsesOciIam.java b/examples/agenthub/openai/QuickstartResponsesOciIam.java new file mode 100644 index 0000000..634f542 --- /dev/null +++ b/examples/agenthub/openai/QuickstartResponsesOciIam.java @@ -0,0 +1,63 @@ +/* + * 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/ + */ + +/** + * Quickstart using OCI IAM authentication with the Responses API on AgentHub. + * + *

Steps: + *

    + *
  1. Create a Generative AI Project on OCI Console
  2. + *
  3. Add oci-genai-auth-java-core dependency
  4. + *
  5. Run this example
  6. + *
+ */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +public class QuickstartResponsesOciIam { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String PROJECT_OCID = "<>"; + private static final String MODEL = "xai.grok-3"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/openai/v1"; + + public static void main(String[] args) { + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // AgentHub only needs project OCID — no compartment ID required + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .addHeader("openai-project", PROJECT_OCID) + .build(); + + Response response = client.responses().create( + ResponseCreateParams.builder() + .model(MODEL) + .input("What is 2x2?") + .build()); + + System.out.println(response.outputText()); + } +} diff --git a/examples/agenthub/openai/responses/CreateResponse.java b/examples/agenthub/openai/responses/CreateResponse.java new file mode 100644 index 0000000..7986335 --- /dev/null +++ b/examples/agenthub/openai/responses/CreateResponse.java @@ -0,0 +1,56 @@ +/* + * 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/ + */ + +/** + * Demonstrates creating a response with the Responses API on AgentHub. + */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +public class CreateResponse { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String PROJECT_OCID = "<>"; + private static final String MODEL = "xai.grok-3"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/openai/v1"; + + public static void main(String[] args) { + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // AgentHub only needs project OCID — no compartment ID required + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .addHeader("openai-project", PROJECT_OCID) + .build(); + + Response response = client.responses().create( + ResponseCreateParams.builder() + .model(MODEL) + .input("What is 2x2?") + .build()); + + System.out.println(response.outputText()); + } +} diff --git a/examples/agenthub/openai/responses/StreamingTextDelta.java b/examples/agenthub/openai/responses/StreamingTextDelta.java new file mode 100644 index 0000000..33e571a --- /dev/null +++ b/examples/agenthub/openai/responses/StreamingTextDelta.java @@ -0,0 +1,60 @@ +/* + * 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/ + */ + +/** + * Demonstrates streaming Responses API output and handling text deltas. + */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.ResponseCreateParams; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +public class StreamingTextDelta { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String PROJECT_OCID = "<>"; + private static final String MODEL = "xai.grok-3"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/openai/v1"; + + public static void main(String[] args) { + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // AgentHub only needs project OCID — no compartment ID required + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .addHeader("openai-project", PROJECT_OCID) + .build(); + + client.responses().createStreaming( + ResponseCreateParams.builder() + .model(MODEL) + .input("What are the shapes of OCI GPUs?") + .build()) + .stream() + .forEach(event -> { + event.asResponseOutputTextDeltaEvent().ifPresent(delta -> + System.out.print(delta.delta())); + }); + + System.out.println(); + } +} diff --git a/examples/agenthub/openai/tools/FunctionCalling.java b/examples/agenthub/openai/tools/FunctionCalling.java new file mode 100644 index 0000000..4d38cdf --- /dev/null +++ b/examples/agenthub/openai/tools/FunctionCalling.java @@ -0,0 +1,107 @@ +/* + * 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/ + */ + +/** + * Demonstrates function calling tools in AgentHub using the Responses API. + */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.*; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +import java.util.List; +import java.util.Map; + +public class FunctionCalling { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String PROJECT_OCID = "<>"; + private static final String MODEL = "xai.grok-3"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/openai/v1"; + + /** Mock weather function. */ + private static String getCurrentWeather(String location) { + return """ + {"location": "%s", "temperature": "72", "unit": "fahrenheit", "forecast": ["sunny", "windy"]} + """.formatted(location).trim(); + } + + public static void main(String[] args) { + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // AgentHub only needs project OCID — no compartment ID required + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .addHeader("openai-project", PROJECT_OCID) + .build(); + + // Define function tool + Tool weatherTool = Tool.ofFunction(FunctionTool.builder() + .name("get_current_weather") + .description("Get current weather for a given location.") + .strict(true) + .parameters(FunctionTool.Parameters.builder() + .putAdditionalProperty("type", "object") + .putAdditionalProperty("properties", Map.of( + "location", Map.of( + "type", "string", + "description", "City and country e.g. Bogota, Colombia"))) + .putAdditionalProperty("required", List.of("location")) + .putAdditionalProperty("additionalProperties", false) + .build()) + .build()); + + // First request — model decides to call the function + Response response = client.responses().create( + ResponseCreateParams.builder() + .model(MODEL) + .input("What is the weather in Seattle?") + .addTool(weatherTool) + .build()); + + System.out.println("First response: " + response.output()); + + // If the model requested a function call, execute it and send the result back + for (ResponseOutputItem item : response.output()) { + item.functionCall().ifPresent(fc -> { + String result = getCurrentWeather("Seattle"); + + Response followUp = client.responses().create( + ResponseCreateParams.builder() + .model(MODEL) + .addInputItem(ResponseInputItem.ofFunctionCallOutput( + ResponseInputItem.FunctionCallOutput.builder() + .callId(fc.callId()) + .output(result) + .build())) + .addTool(weatherTool) + .build()); + + followUp.output().forEach(out -> + out.message().ifPresent(msg -> + msg.content().forEach(c -> + c.outputText().ifPresent(t -> + System.out.println("Final response: " + t.text()))))); + }); + } + } +} diff --git a/examples/agenthub/openai/tools/WebSearch.java b/examples/agenthub/openai/tools/WebSearch.java new file mode 100644 index 0000000..c70864d --- /dev/null +++ b/examples/agenthub/openai/tools/WebSearch.java @@ -0,0 +1,59 @@ +/* + * 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/ + */ + +/** + * Demonstrates the web_search tool in AgentHub. + */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.Tool; +import com.openai.models.responses.WebSearchTool; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +public class WebSearch { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String PROJECT_OCID = "<>"; + private static final String MODEL = "openai.gpt-4.1"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/openai/v1"; + + public static void main(String[] args) { + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // AgentHub only needs project OCID — no compartment ID required + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .addHeader("openai-project", PROJECT_OCID) + .build(); + + Response response = client.responses().create( + ResponseCreateParams.builder() + .model(MODEL) + .addTool(Tool.ofWebSearch(WebSearchTool.builder().build())) + .input("What was a positive news story on 2025-11-14?") + .build()); + + System.out.println(response.outputText()); + } +} diff --git a/examples/partner/openai/BasicChatCompletion.java b/examples/partner/openai/BasicChatCompletion.java new file mode 100644 index 0000000..66293e5 --- /dev/null +++ b/examples/partner/openai/BasicChatCompletion.java @@ -0,0 +1,65 @@ +/* + * 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/ + */ + +/** + * Demonstrates a basic chat completion request for the Partner (pass-through) endpoint. + * + *

This file is a standalone example — it is NOT compiled as part of the Maven build. + * Copy it into your own project and add the required dependencies. + */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +public class BasicChatCompletion { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String COMPARTMENT_ID = "<>"; + private static final String MODEL = "openai.gpt-4.1"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/20231130/actions/v1"; + + public static void main(String[] args) { + // 1. Build an OCI-signed OkHttpClient + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .compartmentId(COMPARTMENT_ID) + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // 2. Plug the OCI-signed client into the OpenAI SDK + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .build(); + + // 3. Send a chat completion request + ChatCompletion completion = client.chat().completions().create( + ChatCompletionCreateParams.builder() + .model(MODEL) + .addSystemMessage("You are a concise assistant.") + .addUserMessage("List three creative uses for a paperclip.") + .maxTokens(128) + .build()); + + // 4. Print the response + completion.choices().forEach(choice -> + choice.message().content().ifPresent(System.out::println)); + } +} diff --git a/examples/partner/openai/BasicChatCompletionApiKey.java b/examples/partner/openai/BasicChatCompletionApiKey.java new file mode 100644 index 0000000..be528ec --- /dev/null +++ b/examples/partner/openai/BasicChatCompletionApiKey.java @@ -0,0 +1,45 @@ +/* + * 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/ + */ + +/** + * Demonstrates chat completion using OCI Generative AI API Key authentication. + * + *

No oci-genai-auth package needed for API Key auth — just the official OpenAI SDK. + * + *

Steps: + *

    + *
  1. Create API keys in Console: Generative AI → API Keys
  2. + *
  3. Set OPENAI_API_KEY environment variable
  4. + *
  5. Run this example
  6. + *
+ */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +public class BasicChatCompletionApiKey { + + private static final String MODEL = "openai.gpt-4.1"; + + public static void main(String[] args) { + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl("https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1") + .apiKey(System.getenv("OCI_GENAI_API_KEY")) + .build(); + + ChatCompletion completion = client.chat().completions().create( + ChatCompletionCreateParams.builder() + .model(MODEL) + .addSystemMessage("You are a concise assistant who answers in one paragraph.") + .addUserMessage("Explain why the sky is blue as if you were a physics teacher.") + .build()); + + completion.choices().forEach(choice -> + choice.message().content().ifPresent(System.out::println)); + } +} diff --git a/examples/partner/openai/README.md b/examples/partner/openai/README.md new file mode 100644 index 0000000..9ddf333 --- /dev/null +++ b/examples/partner/openai/README.md @@ -0,0 +1,31 @@ +# Partner Examples + +Partner endpoints provide access to OpenAI, xAI Grok, and Meta Llama models via the +OpenAI Chat Completions API. The compartment OCID header is required. + +## Prerequisites + +1. Update `COMPARTMENT_ID` and `REGION` in each example file. +2. Authenticate with OCI: + - **OCI IAM**: `oci session authenticate` + - **API Key**: Set `OCI_GENAI_API_KEY` environment variable + +## Base URL + +``` +https://inference.generativeai..oci.oraclecloud.com/20231130/actions/v1 +``` + +## Examples + +| File | Description | +|------|-------------| +| `BasicChatCompletion.java` | Basic chat completion with OCI IAM auth | +| `BasicChatCompletionApiKey.java` | Chat completion with API key auth | +| `StreamingChatCompletion.java` | Streaming chat completion | +| `ToolCallChatCompletion.java` | Function/tool calling with chat completions | + +## Running + +These are standalone Java files. Copy them into your project with the required dependencies +(`oci-genai-auth-java-core` and `openai-java`), then compile and run. diff --git a/examples/partner/openai/StreamingChatCompletion.java b/examples/partner/openai/StreamingChatCompletion.java new file mode 100644 index 0000000..38ca1bf --- /dev/null +++ b/examples/partner/openai/StreamingChatCompletion.java @@ -0,0 +1,61 @@ +/* + * 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/ + */ + +/** + * Demonstrates streaming chat completion responses for the Partner (pass-through) endpoint. + */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.chat.completions.ChatCompletionChunk; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +public class StreamingChatCompletion { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String COMPARTMENT_ID = "<>"; + private static final String MODEL = "openai.gpt-4.1"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/20231130/actions/v1"; + + public static void main(String[] args) { + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .compartmentId(COMPARTMENT_ID) + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .build(); + + // Stream the response + client.chat().completions().createStreaming( + ChatCompletionCreateParams.builder() + .model(MODEL) + .addSystemMessage("You are a concise assistant who answers in one paragraph.") + .addUserMessage("Explain why the sky is blue as if you were a physics teacher.") + .build()) + .stream() + .flatMap(chunk -> chunk.choices().stream()) + .forEach(choice -> choice.delta().content().ifPresent( + content -> System.out.print(content))); + + System.out.println(); + } +} diff --git a/examples/partner/openai/ToolCallChatCompletion.java b/examples/partner/openai/ToolCallChatCompletion.java new file mode 100644 index 0000000..5262635 --- /dev/null +++ b/examples/partner/openai/ToolCallChatCompletion.java @@ -0,0 +1,113 @@ +/* + * 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/ + */ + +/** + * Demonstrates tool calling with chat completions for the Partner (pass-through) endpoint. + */ + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.chat.completions.*; + +import com.oracle.genai.auth.OciAuthConfig; +import com.oracle.genai.auth.OciOkHttpClientFactory; + +import okhttp3.OkHttpClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ToolCallChatCompletion { + + // ── Configuration ────────────────────────────────────────────────── + private static final String REGION = "us-chicago-1"; + private static final String COMPARTMENT_ID = "<>"; + private static final String MODEL = "openai.gpt-4.1"; + // ──────────────────────────────────────────────────────────────────── + + private static final String BASE_URL = + "https://inference.generativeai." + REGION + ".oci.oraclecloud.com/20231130/actions/v1"; + + /** Mock weather function. */ + private static String getCurrentWeather(String location) { + return """ + {"location": "%s", "temperature": "72", "unit": "fahrenheit", "forecast": ["sunny", "windy"]} + """.formatted(location).trim(); + } + + public static void main(String[] args) { + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .compartmentId(COMPARTMENT_ID) + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + OpenAIClient client = OpenAIOkHttpClient.builder() + .baseUrl(BASE_URL) + .okHttpClient(ociHttpClient) + .apiKey("not-used") + .build(); + + // Define the function tool + ChatCompletionTool weatherTool = ChatCompletionTool.builder() + .function(FunctionDefinition.builder() + .name("get_current_weather") + .description("Get the current weather for a specific location.") + .parameters(ChatCompletionTool.Function.Parameters.builder() + .putAdditionalProperty("type", "object") + .putAdditionalProperty("properties", Map.of( + "location", Map.of( + "type", "string", + "description", "City and state, for example Boston, MA."))) + .putAdditionalProperty("required", List.of("location")) + .build()) + .build()) + .build(); + + // First request + ChatCompletion first = client.chat().completions().create( + ChatCompletionCreateParams.builder() + .model(MODEL) + .addUserMessage("What is the weather in Boston and San Francisco?") + .addTool(weatherTool) + .toolChoice(ChatCompletionToolChoiceOption.AUTO) + .build()); + + ChatCompletion.Choice firstChoice = first.choices().get(0); + + if ("tool_calls".equals(firstChoice.finishReason().toString())) { + // Execute tool calls and send results back + List messages = new ArrayList<>(); + messages.add(ChatCompletionCreateParams.Message.ofUser( + UserMessage.of("What is the weather in Boston and San Francisco?"))); + messages.add(ChatCompletionCreateParams.Message.ofAssistant( + firstChoice.message())); + + for (var toolCall : firstChoice.message().toolCalls().orElse(List.of())) { + String result = getCurrentWeather(toolCall.function().name()); + messages.add(ChatCompletionCreateParams.Message.ofTool( + ToolMessage.builder() + .toolCallId(toolCall.id()) + .content(result) + .build())); + } + + ChatCompletion followUp = client.chat().completions().create( + ChatCompletionCreateParams.builder() + .model(MODEL) + .messages(messages) + .build()); + + followUp.choices().forEach(choice -> + choice.message().content().ifPresent(System.out::println)); + } else { + firstChoice.message().content().ifPresent(System.out::println); + } + } +} diff --git a/oci-genai-auth-java-bom/pom.xml b/oci-genai-auth-java-bom/pom.xml new file mode 100644 index 0000000..c751d98 --- /dev/null +++ b/oci-genai-auth-java-bom/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + com.oracle.genai + oci-genai-auth-java-bom + 0.1.0-SNAPSHOT + pom + + OCI GenAI Auth :: BOM + + Bill of Materials (BOM) for the OCI GenAI Auth library. + Import this POM to pin the core module version and key transitive + dependency versions, preventing diamond dependency conflicts. + + + + 0.1.0-SNAPSHOT + + + 3.72.1 + + + 5.3.2 + 2.0.17 + + + + + + + com.oracle.genai + oci-genai-auth-java-core + ${oci-genai-auth.version} + + + + + com.oracle.oci.sdk + oci-java-sdk-common + ${oci-sdk.version} + + + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + + com.squareup.okhttp3 + logging-interceptor + ${okhttp.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + diff --git a/oci-genai-auth-java-core/pom.xml b/oci-genai-auth-java-core/pom.xml new file mode 100644 index 0000000..89d9889 --- /dev/null +++ b/oci-genai-auth-java-core/pom.xml @@ -0,0 +1,62 @@ + + + + 4.0.0 + + + com.oracle.genai + oci-genai-auth-java-parent + 0.1.0-SNAPSHOT + + + oci-genai-auth-java-core + jar + + OCI GenAI Auth :: Core + + OCI request signing and authentication utilities for calling OCI-hosted + AI compatibility endpoints. Provides OCI IAM auth providers, per-request + signing interceptor, compartment header injection, and endpoint resolution. + + + + + + com.oracle.oci.sdk + oci-java-sdk-common + + + + + com.squareup.okhttp3 + okhttp + + + + + com.squareup.okhttp3 + logging-interceptor + + + + + org.slf4j + slf4j-api + + + + + com.openai + openai-java + 4.26.0 + test + + + diff --git a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthConfig.java b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthConfig.java new file mode 100644 index 0000000..4c42b51 --- /dev/null +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthConfig.java @@ -0,0 +1,102 @@ +/* + * 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 java.time.Duration; + +/** + * Configuration for OCI authentication and endpoint resolution. + * + *

Use the {@link #builder()} to create a config instance: + *

{@code
+ * OciAuthConfig config = OciAuthConfig.builder()
+ *         .authType("security_token")
+ *         .profile("DEFAULT")
+ *         .compartmentId("ocid1.compartment.oc1..xxx")
+ *         .region("us-chicago-1")
+ *         .build();
+ * }
+ * + *

Supported auth types: + *

    + *
  • {@code oci_config} — User principal from {@code ~/.oci/config}
  • + *
  • {@code security_token} — Session token from OCI CLI session
  • + *
  • {@code instance_principal} — For OCI Compute instances
  • + *
  • {@code resource_principal} — For OCI Functions, Container Instances, etc.
  • + *
+ */ +public final class OciAuthConfig { + + private final String authType; + private final String profile; + private final String region; + private final String baseUrl; + private final String compartmentId; + private final Duration timeout; + + private OciAuthConfig(Builder builder) { + this.authType = builder.authType; + this.profile = builder.profile; + this.region = builder.region; + this.baseUrl = builder.baseUrl; + this.compartmentId = builder.compartmentId; + this.timeout = builder.timeout; + } + + public String authType() { return authType; } + public String profile() { return profile; } + public String region() { return region; } + public String baseUrl() { return baseUrl; } + public String compartmentId() { return compartmentId; } + public Duration timeout() { return timeout; } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String authType; + private String profile; + private String region; + private String baseUrl; + private String compartmentId; + private Duration timeout; + + private Builder() {} + + /** + * Sets the OCI authentication type. + * One of: {@code oci_config}, {@code security_token}, + * {@code instance_principal}, {@code resource_principal}. + */ + public Builder authType(String authType) { this.authType = authType; return this; } + + /** + * Sets the OCI config profile name. Used with {@code oci_config} and + * {@code security_token} auth types. Defaults to {@code "DEFAULT"}. + */ + public Builder profile(String profile) { this.profile = profile; return this; } + + /** Sets the OCI region code (e.g., {@code "us-chicago-1"}). */ + public Builder region(String region) { this.region = region; return this; } + + /** Sets the fully qualified OCI base URL (overrides region-based resolution). */ + public Builder baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; } + + /** Sets the OCI compartment OCID. */ + public Builder compartmentId(String compartmentId) { this.compartmentId = compartmentId; return this; } + + /** Sets the request timeout. Defaults to 2 minutes. */ + public Builder timeout(Duration timeout) { this.timeout = timeout; return this; } + + public OciAuthConfig build() { + if (authType == null || authType.isBlank()) { + throw new IllegalArgumentException("authType is required"); + } + return new OciAuthConfig(this); + } + } +} diff --git a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthException.java b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthException.java new file mode 100644 index 0000000..345fdbc --- /dev/null +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthException.java @@ -0,0 +1,21 @@ +/* + * 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; + +/** + * Thrown when an OCI authentication provider cannot be created or + * when request signing fails. + */ +public class OciAuthException extends RuntimeException { + + public OciAuthException(String message) { + super(message); + } + + public OciAuthException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthProviderFactory.java b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthProviderFactory.java new file mode 100644 index 0000000..81b5c15 --- /dev/null +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciAuthProviderFactory.java @@ -0,0 +1,112 @@ +/* + * 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.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider; +import com.oracle.bmc.auth.InstancePrincipalsAuthenticationDetailsProvider; +import com.oracle.bmc.auth.ResourcePrincipalAuthenticationDetailsProvider; +import com.oracle.bmc.auth.SessionTokenAuthenticationDetailsProvider; + +/** + * Factory that creates OCI {@link BasicAuthenticationDetailsProvider} instances + * based on the requested authentication type. + * + *

Supported auth types: + *

    + *
  • {@code oci_config} — User principal from {@code ~/.oci/config}
  • + *
  • {@code security_token} — Session token from OCI CLI session
  • + *
  • {@code instance_principal} — For OCI Compute instances
  • + *
  • {@code resource_principal} — For OCI Functions, Container Instances, etc.
  • + *
+ */ +public final class OciAuthProviderFactory { + + public static final String AUTH_TYPE_OCI_CONFIG = "oci_config"; + public static final String AUTH_TYPE_SECURITY_TOKEN = "security_token"; + public static final String AUTH_TYPE_INSTANCE_PRINCIPAL = "instance_principal"; + public static final String AUTH_TYPE_RESOURCE_PRINCIPAL = "resource_principal"; + + private static final String DEFAULT_PROFILE = "DEFAULT"; + + private OciAuthProviderFactory() { + // utility class + } + + /** + * Creates an authentication provider for the given auth type. + * + * @param authType the OCI authentication type + * @param profile the OCI config profile name (used for oci_config and security_token) + * @return a configured {@link BasicAuthenticationDetailsProvider} + * @throws IllegalArgumentException if authType is not recognized + * @throws OciAuthException if the provider cannot be created + */ + public static BasicAuthenticationDetailsProvider create(String authType, String profile) { + if (authType == null || authType.isBlank()) { + throw new IllegalArgumentException("authType must not be null or blank"); + } + + String resolvedProfile = (profile == null || profile.isBlank()) ? DEFAULT_PROFILE : profile; + + return switch (authType) { + case AUTH_TYPE_OCI_CONFIG -> createConfigProvider(resolvedProfile); + case AUTH_TYPE_SECURITY_TOKEN -> createSessionTokenProvider(resolvedProfile); + case AUTH_TYPE_INSTANCE_PRINCIPAL -> createInstancePrincipalProvider(); + case AUTH_TYPE_RESOURCE_PRINCIPAL -> createResourcePrincipalProvider(); + default -> throw new IllegalArgumentException( + "Unsupported authType: '" + authType + "'. " + + "Supported values: oci_config, security_token, instance_principal, resource_principal"); + }; + } + + /** + * Creates an authentication provider for the given auth type using the DEFAULT profile. + */ + public static BasicAuthenticationDetailsProvider create(String authType) { + return create(authType, DEFAULT_PROFILE); + } + + private static BasicAuthenticationDetailsProvider createConfigProvider(String profile) { + try { + return new ConfigFileAuthenticationDetailsProvider(profile); + } catch (Exception e) { + throw new OciAuthException( + "Failed to create OCI config auth provider for profile '" + profile + "'. " + + "Ensure ~/.oci/config exists and the profile is valid.", e); + } + } + + private static BasicAuthenticationDetailsProvider createSessionTokenProvider(String profile) { + try { + return new SessionTokenAuthenticationDetailsProvider(profile); + } catch (Exception e) { + throw new OciAuthException( + "Failed to create session token auth provider for profile '" + profile + "'. " + + "Run 'oci session authenticate' to create a session.", e); + } + } + + private static BasicAuthenticationDetailsProvider createInstancePrincipalProvider() { + try { + return InstancePrincipalsAuthenticationDetailsProvider.builder().build(); + } catch (Exception e) { + throw new OciAuthException( + "Failed to create instance principal auth provider. " + + "Ensure this code is running on an OCI Compute instance with a configured dynamic group.", e); + } + } + + private static BasicAuthenticationDetailsProvider createResourcePrincipalProvider() { + try { + return ResourcePrincipalAuthenticationDetailsProvider.builder().build(); + } catch (Exception e) { + throw new OciAuthException( + "Failed to create resource principal auth provider. " + + "Ensure this code is running in an OCI Function or Container Instance with RP configured.", e); + } + } +} diff --git a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciEndpointResolver.java b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciEndpointResolver.java new file mode 100644 index 0000000..6891698 --- /dev/null +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciEndpointResolver.java @@ -0,0 +1,100 @@ +/* + * 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 java.net.URI; +import java.util.Locale; + +/** + * Resolves the OCI Generative AI service base URL from region, service endpoint, + * or an explicit base URL override. + * + *

Resolution priority (highest to lowest): + *

    + *
  1. {@code baseUrl} — fully qualified OCI URL, used as-is (HTTPS + OCI domain required)
  2. + *
  3. {@code serviceEndpoint} — service root; must be an OCI domain ({@code *.oraclecloud.com})
  4. + *
  5. {@code region} — auto-derives the service endpoint from the OCI region code
  6. + *
+ */ +public final class OciEndpointResolver { + + private static final String SERVICE_ENDPOINT_TEMPLATE = + "https://inference.generativeai.%s.oci.oraclecloud.com"; + + private static final String OCI_DOMAIN_SUFFIX = ".oraclecloud.com"; + + private OciEndpointResolver() { + // utility class + } + + /** + * Resolves a base URL with a caller-supplied API path suffix. + * + * @param region OCI region code (e.g., "us-chicago-1") + * @param serviceEndpoint service root URL (without API path; must be an OCI domain) + * @param baseUrl fully qualified OCI URL override (used as-is when provided) + * @param apiPath the API path to append to the service endpoint + * @return the resolved base URL + * @throws IllegalArgumentException if none of region, serviceEndpoint, or baseUrl is provided + */ + public static String resolveBaseUrl(String region, String serviceEndpoint, String baseUrl, String apiPath) { + if (baseUrl != null && !baseUrl.isBlank()) { + validateUrl(baseUrl, "baseUrl"); + return stripTrailingSlash(baseUrl); + } + + if (serviceEndpoint != null && !serviceEndpoint.isBlank()) { + validateUrl(serviceEndpoint, "serviceEndpoint"); + return stripTrailingSlash(serviceEndpoint) + apiPath; + } + + if (region != null && !region.isBlank()) { + return String.format(SERVICE_ENDPOINT_TEMPLATE, region) + apiPath; + } + + throw new IllegalArgumentException( + "At least one of region, serviceEndpoint, or baseUrl must be provided."); + } + + /** + * Builds the service endpoint URL from a region code. + */ + public static String buildServiceEndpoint(String region) { + if (region == null || region.isBlank()) { + throw new IllegalArgumentException("region must not be null or blank"); + } + return String.format(SERVICE_ENDPOINT_TEMPLATE, region); + } + + private static void validateUrl(String url, String paramName) { + URI uri; + try { + uri = URI.create(url); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(paramName + " must be a valid absolute HTTPS URL: " + url, e); + } + + if (uri.getScheme() == null || !"https".equalsIgnoreCase(uri.getScheme())) { + throw new IllegalArgumentException(paramName + " must use HTTPS: " + url); + } + + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException(paramName + " must include a valid host: " + url); + } + if (uri.getRawUserInfo() != null) { + throw new IllegalArgumentException(paramName + " must not include user-info: " + url); + } + if (!host.toLowerCase(Locale.ROOT).endsWith(OCI_DOMAIN_SUFFIX)) { + throw new IllegalArgumentException( + paramName + " must be an OCI domain (*" + OCI_DOMAIN_SUFFIX + "): " + url); + } + } + + private static String stripTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciHeaderInterceptor.java b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciHeaderInterceptor.java new file mode 100644 index 0000000..1d61ea6 --- /dev/null +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciHeaderInterceptor.java @@ -0,0 +1,65 @@ +/* + * 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 okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +/** + * OkHttp {@link Interceptor} that injects OCI-specific headers into every request. + * + *

This handles the {@code CompartmentId} and {@code opc-compartment-id} headers + * required by the OCI Generative AI service, as well as any additional custom headers. + */ +public class OciHeaderInterceptor implements Interceptor { + + public static final String COMPARTMENT_ID_HEADER = "CompartmentId"; + public static final String OPC_COMPARTMENT_ID_HEADER = "opc-compartment-id"; + + private final Map headers; + + /** + * Creates a header interceptor with compartment ID and optional extra headers. + * + * @param compartmentId the OCI compartment OCID (may be null if not required) + * @param additionalHeaders extra headers to inject on every request + */ + public OciHeaderInterceptor(String compartmentId, Map additionalHeaders) { + var headerMap = new java.util.LinkedHashMap(); + + if (compartmentId != null && !compartmentId.isBlank()) { + headerMap.put(COMPARTMENT_ID_HEADER, compartmentId); + headerMap.put(OPC_COMPARTMENT_ID_HEADER, compartmentId); + } + + if (additionalHeaders != null) { + headerMap.putAll(additionalHeaders); + } + + this.headers = Collections.unmodifiableMap(headerMap); + } + + /** + * Creates a header interceptor with compartment ID only. + */ + public OciHeaderInterceptor(String compartmentId) { + this(compartmentId, null); + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request.Builder builder = chain.request().newBuilder(); + for (Map.Entry entry : headers.entrySet()) { + builder.header(entry.getKey(), entry.getValue()); + } + return chain.proceed(builder.build()); + } +} diff --git a/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciOkHttpClientFactory.java b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciOkHttpClientFactory.java new file mode 100644 index 0000000..d5d8b03 --- /dev/null +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciOkHttpClientFactory.java @@ -0,0 +1,124 @@ +/* + * 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.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import okhttp3.OkHttpClient; +import okhttp3.logging.HttpLoggingInterceptor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Factory for creating OkHttp clients pre-configured with OCI signing and header interceptors. + * + *

Use this factory to obtain an OkHttpClient that transparently handles OCI IAM + * authentication on every request. The returned client can be used directly or plugged + * into any vendor SDK that accepts a custom OkHttpClient or HTTP transport. + * + *

Quick Start

+ *
{@code
+ * OciAuthConfig config = OciAuthConfig.builder()
+ *         .authType("security_token")
+ *         .compartmentId("ocid1.compartment.oc1..xxx")
+ *         .build();
+ *
+ * OkHttpClient httpClient = OciOkHttpClientFactory.build(config);
+ * }
+ */ +public final class OciOkHttpClientFactory { + + private static final Logger LOG = LoggerFactory.getLogger(OciOkHttpClientFactory.class); + private static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(2); + + /** Headers that are redacted from debug logging to prevent credential leakage. */ + private static final Set REDACTED_HEADERS = Set.of( + "authorization", "x-content-sha256", "opc-request-id"); + + private OciOkHttpClientFactory() { + // utility class + } + + /** + * Creates an OCI-signed OkHttpClient from the given config. + * + * @param config the OCI auth configuration + * @return a configured OkHttpClient with signing and header interceptors + */ + public static OkHttpClient build(OciAuthConfig config) { + Objects.requireNonNull(config, "config must not be null"); + + BasicAuthenticationDetailsProvider authProvider = + OciAuthProviderFactory.create(config.authType(), config.profile()); + + return create(authProvider, config.compartmentId(), null, config.timeout(), false); + } + + /** + * Creates an OkHttpClient with OCI signing and header injection. + * + * @param authProvider the OCI authentication provider + * @param compartmentId the OCI compartment OCID (may be null) + * @param additionalHeaders extra headers to inject on every request + * @param timeout request timeout (null for default 2 minutes) + * @param logRequests whether to log request/response bodies + * @return a configured OkHttpClient + */ + public static OkHttpClient create( + BasicAuthenticationDetailsProvider authProvider, + String compartmentId, + Map additionalHeaders, + Duration timeout, + boolean logRequests) { + + Objects.requireNonNull(authProvider, "authProvider must not be null"); + + Duration resolvedTimeout = timeout != null ? timeout : DEFAULT_TIMEOUT; + + OkHttpClient.Builder builder = new OkHttpClient.Builder() + .addInterceptor(new OciHeaderInterceptor(compartmentId, additionalHeaders)) + .addInterceptor(new OciSigningInterceptor(authProvider)) + .connectTimeout(resolvedTimeout) + .readTimeout(resolvedTimeout) + .writeTimeout(resolvedTimeout); + + if (logRequests) { + HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor( + message -> { + // Redact sensitive headers (Authorization, x-content-sha256) + // to prevent OCI signing credentials from appearing in logs. + String redacted = message; + for (String header : REDACTED_HEADERS) { + if (redacted.toLowerCase().startsWith(header + ":")) { + redacted = header + ": [REDACTED]"; + break; + } + } + LOG.debug("{}", redacted); + }); + loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.HEADERS); + builder.addInterceptor(loggingInterceptor); + } + + LOG.debug("Created OCI-signed OkHttpClient [compartmentId={}, timeout={}]", + compartmentId, resolvedTimeout); + + return builder.build(); + } + + /** + * Creates an OkHttpClient with OCI signing, compartment header, and default settings. + */ + public static OkHttpClient create( + BasicAuthenticationDetailsProvider authProvider, + String compartmentId) { + return create(authProvider, compartmentId, null, null, false); + } +} 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 new file mode 100644 index 0000000..78328b6 --- /dev/null +++ b/oci-genai-auth-java-core/src/main/java/com/oracle/genai/auth/OciSigningInterceptor.java @@ -0,0 +1,145 @@ +/* + * 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.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import com.oracle.bmc.http.signing.DefaultRequestSigner; +import com.oracle.bmc.http.signing.RequestSigner; +import com.oracle.bmc.http.signing.SigningStrategy; +import com.oracle.bmc.http.client.io.DuplicatableInputStream; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.Buffer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * OkHttp {@link Interceptor} that applies OCI request signing to every outgoing request. + * + *

This interceptor computes the OCI signature (RSA-SHA256 with timestamp, nonce, + * and body digest) and injects the {@code Authorization}, {@code date}, + * {@code (request-target)}, {@code host}, and content-related headers required by + * the OCI API gateway. + * + *

Thread-safe: this interceptor can be shared across multiple OkHttp clients. + * Token refresh for {@code security_token} and principal-based auth is handled + * transparently by the underlying {@link BasicAuthenticationDetailsProvider}. + */ +public class OciSigningInterceptor implements Interceptor { + + private static final Logger LOG = LoggerFactory.getLogger(OciSigningInterceptor.class); + + private final RequestSigner requestSigner; + + /** + * Creates a signing interceptor using the given OCI auth provider. + * + * @param authProvider the OCI authentication details provider (must not be null) + */ + public OciSigningInterceptor(BasicAuthenticationDetailsProvider authProvider) { + Objects.requireNonNull(authProvider, "authProvider must not be null"); + this.requestSigner = DefaultRequestSigner.createRequestSigner( + authProvider, SigningStrategy.STANDARD); + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + + 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. + 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()) + .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(); + } + + // Compute OCI signature headers + Map signedHeaders; + if (bodyBytes != null && bodyBytes.length > 0) { + signedHeaders = requestSigner.signRequest( + uri, method, existingHeaders, + new DuplicatableByteArrayInputStream(bodyBytes)); + } else { + signedHeaders = requestSigner.signRequest( + uri, method, existingHeaders, null); + } + + // Build a new request with all signed headers applied + Request.Builder signedRequestBuilder = originalRequest.newBuilder(); + for (Map.Entry entry : signedHeaders.entrySet()) { + signedRequestBuilder.header(entry.getKey(), entry.getValue()); + } + + // Re-attach the body (it was consumed during signing). + // Use byte[] to prevent OkHttp from re-appending "; charset=utf-8". + if (bodyBytes != null) { + MediaType contentType = originalRequest.body() != null + ? originalRequest.body().contentType() + : MediaType.parse("application/json"); + // Strip charset to keep content-type consistent with what was signed + if (contentType != null && contentType.charset() != null) { + contentType = MediaType.parse(contentType.type() + "/" + contentType.subtype()); + } + signedRequestBuilder.method(method, RequestBody.create(bodyBytes, contentType)); + } + + Request signedRequest = signedRequestBuilder.build(); + LOG.debug("OCI-signed request: {} {}", method, uri); + + return chain.proceed(signedRequest); + } + + /** + * A {@link ByteArrayInputStream} that implements {@link DuplicatableInputStream}, + * required by OCI SDK 3.x {@code RequestSignerImpl} for body signing. + */ + private static class DuplicatableByteArrayInputStream + extends ByteArrayInputStream implements DuplicatableInputStream { + + private final byte[] data; + + DuplicatableByteArrayInputStream(byte[] data) { + super(data); + this.data = data; + } + + @Override + public InputStream duplicate() { + return new DuplicatableByteArrayInputStream(data); + } + } +} diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciAuthProviderFactoryTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciAuthProviderFactoryTest.java new file mode 100644 index 0000000..90f51f2 --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciAuthProviderFactoryTest.java @@ -0,0 +1,45 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class OciAuthProviderFactoryTest { + + @Test + void create_throwsOnNullAuthType() { + assertThrows(IllegalArgumentException.class, () -> + OciAuthProviderFactory.create(null)); + } + + @Test + void create_throwsOnBlankAuthType() { + assertThrows(IllegalArgumentException.class, () -> + OciAuthProviderFactory.create(" ")); + } + + @Test + void create_throwsOnUnsupportedAuthType() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + OciAuthProviderFactory.create("unknown_type")); + assertTrue(ex.getMessage().contains("Unsupported authType")); + assertTrue(ex.getMessage().contains("unknown_type")); + } + + @Test + void create_ociConfigThrowsGracefullyWhenNoConfigFile() { + assertThrows(OciAuthException.class, () -> + OciAuthProviderFactory.create("oci_config", "NONEXISTENT_PROFILE_XYZ")); + } + + @Test + void create_securityTokenThrowsGracefullyWhenNoSession() { + assertThrows(OciAuthException.class, () -> + OciAuthProviderFactory.create("security_token", "NONEXISTENT_PROFILE_XYZ")); + } +} diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciEndpointResolverTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciEndpointResolverTest.java new file mode 100644 index 0000000..8331dd4 --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciEndpointResolverTest.java @@ -0,0 +1,130 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class OciEndpointResolverTest { + + @Test + void resolveBaseUrl_fromRegion() { + String url = OciEndpointResolver.resolveBaseUrl("us-chicago-1", null, null, "/v1/test"); + assertEquals( + "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/v1/test", + url); + } + + @Test + void resolveBaseUrl_fromServiceEndpoint() { + String url = OciEndpointResolver.resolveBaseUrl( + null, "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com", null, "/v1/test"); + assertEquals( + "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/v1/test", + url); + } + + @Test + void resolveBaseUrl_fromBaseUrl() { + String url = OciEndpointResolver.resolveBaseUrl( + null, null, "https://custom-endpoint.oci.oraclecloud.com/v1", "/ignored"); + assertEquals("https://custom-endpoint.oci.oraclecloud.com/v1", url); + } + + @Test + void resolveBaseUrl_baseUrlTakesPrecedence() { + String url = OciEndpointResolver.resolveBaseUrl( + "us-chicago-1", + "https://service.oci.oraclecloud.com", + "https://override.oci.oraclecloud.com/v1", + "/v1/test"); + assertEquals("https://override.oci.oraclecloud.com/v1", url); + } + + @Test + void resolveBaseUrl_serviceEndpointTakesPrecedenceOverRegion() { + String url = OciEndpointResolver.resolveBaseUrl( + "us-chicago-1", + "https://custom-service.oci.oraclecloud.com", + null, + "/v1/test"); + assertEquals("https://custom-service.oci.oraclecloud.com/v1/test", url); + } + + @Test + void resolveBaseUrl_stripsTrailingSlash() { + String url = OciEndpointResolver.resolveBaseUrl( + null, "https://service.oraclecloud.com/", null, "/v1/test"); + assertEquals("https://service.oraclecloud.com/v1/test", url); + } + + @Test + void resolveBaseUrl_serviceEndpointRejectsNonOciDomain() { + assertThrows(IllegalArgumentException.class, () -> + OciEndpointResolver.resolveBaseUrl( + null, "https://evil.example.com", null, "/v1/test")); + } + + @Test + void resolveBaseUrl_serviceEndpointAcceptsPpeDomain() { + String url = OciEndpointResolver.resolveBaseUrl( + null, + "https://ppe.inference.generativeai.us-chicago-1.oci.oraclecloud.com", + null, + "/v1/test"); + assertEquals( + "https://ppe.inference.generativeai.us-chicago-1.oci.oraclecloud.com/v1/test", + url); + } + + @Test + void resolveBaseUrl_baseUrlRejectsNonOciDomain() { + assertThrows(IllegalArgumentException.class, () -> + OciEndpointResolver.resolveBaseUrl( + null, null, "https://custom-proxy.example.com/v1", "/ignored")); + } + + @Test + void resolveBaseUrl_baseUrlRejectsMissingHost() { + assertThrows(IllegalArgumentException.class, () -> + OciEndpointResolver.resolveBaseUrl( + null, null, "https://", "/ignored")); + } + + @Test + void resolveBaseUrl_baseUrlRejectsUserInfo() { + assertThrows(IllegalArgumentException.class, () -> + OciEndpointResolver.resolveBaseUrl( + null, null, "https://user:pass@inference.generativeai.us-chicago-1.oci.oraclecloud.com/v1", "/ignored")); + } + + @Test + void resolveBaseUrl_serviceEndpointRejectsMissingHost() { + assertThrows(IllegalArgumentException.class, () -> + OciEndpointResolver.resolveBaseUrl( + null, "https://", null, "/v1/test")); + } + + @Test + void resolveBaseUrl_throwsWhenNothingProvided() { + assertThrows(IllegalArgumentException.class, () -> + OciEndpointResolver.resolveBaseUrl(null, null, null, "/v1/test")); + } + + @Test + void buildServiceEndpoint_fromRegion() { + assertEquals( + "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com", + OciEndpointResolver.buildServiceEndpoint("us-chicago-1")); + } + + @Test + void buildServiceEndpoint_throwsOnNull() { + assertThrows(IllegalArgumentException.class, () -> + OciEndpointResolver.buildServiceEndpoint(null)); + } +} diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciHeaderInterceptorTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciHeaderInterceptorTest.java new file mode 100644 index 0000000..42e740f --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciHeaderInterceptorTest.java @@ -0,0 +1,89 @@ +/* + * 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 okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class OciHeaderInterceptorTest { + + private MockWebServer server; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + @Test + void injectsCompartmentIdHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new OciHeaderInterceptor("ocid1.compartment.oc1..test")) + .build(); + + client.newCall(new Request.Builder() + .url(server.url("/test")) + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + assertEquals("ocid1.compartment.oc1..test", request.getHeader("CompartmentId")); + assertEquals("ocid1.compartment.oc1..test", request.getHeader("opc-compartment-id")); + } + + @Test + void injectsAdditionalHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new OciHeaderInterceptor( + "ocid1.compartment.oc1..test", + Map.of("opc-conversation-store-id", "ocid1.store.oc1..test"))) + .build(); + + client.newCall(new Request.Builder() + .url(server.url("/test")) + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + assertEquals("ocid1.compartment.oc1..test", request.getHeader("CompartmentId")); + assertEquals("ocid1.store.oc1..test", request.getHeader("opc-conversation-store-id")); + } + + @Test + void skipsCompartmentHeadersWhenNull() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new OciHeaderInterceptor(null)) + .build(); + + client.newCall(new Request.Builder() + .url(server.url("/test")) + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + assertNull(request.getHeader("CompartmentId")); + assertNull(request.getHeader("opc-compartment-id")); + } +} diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciOkHttpClientFactoryTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciOkHttpClientFactoryTest.java new file mode 100644 index 0000000..4be4f9f --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciOkHttpClientFactoryTest.java @@ -0,0 +1,118 @@ +/* + * 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.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Duration; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OciOkHttpClientFactoryTest { + + private MockWebServer server; + private BasicAuthenticationDetailsProvider authProvider; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + + String pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString( + keyPair.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----"; + + authProvider = mock(BasicAuthenticationDetailsProvider.class); + when(authProvider.getKeyId()).thenReturn( + "ocid1.tenancy.oc1..test/ocid1.user.oc1..test/aa:bb:cc:dd"); + when(authProvider.getPrivateKey()).thenAnswer( + inv -> new ByteArrayInputStream(pem.getBytes())); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + @Test + void create_installsInterceptors() { + OkHttpClient client = OciOkHttpClientFactory.create( + authProvider, "ocid1.compartment.oc1..test"); + + List interceptors = client.interceptors(); + assertTrue(interceptors.stream().anyMatch(i -> i instanceof OciHeaderInterceptor), + "Should contain OciHeaderInterceptor"); + assertTrue(interceptors.stream().anyMatch(i -> i instanceof OciSigningInterceptor), + "Should contain OciSigningInterceptor"); + } + + @Test + void create_requestGoesThoughMockServer() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("ok")); + + OkHttpClient client = OciOkHttpClientFactory.create( + authProvider, "ocid1.compartment.oc1..test"); + + var response = client.newCall(new Request.Builder() + .url(server.url("/test")) + .build()).execute(); + + assertEquals(200, response.code()); + response.close(); + + RecordedRequest request = server.takeRequest(); + assertEquals("ocid1.compartment.oc1..test", request.getHeader("CompartmentId")); + assertNotNull(request.getHeader("Authorization")); + } + + @Test + void create_setsCustomTimeout() { + OkHttpClient client = OciOkHttpClientFactory.create( + authProvider, null, null, Duration.ofSeconds(30), false); + + assertEquals(30_000, client.connectTimeoutMillis()); + assertEquals(30_000, client.readTimeoutMillis()); + assertEquals(30_000, client.writeTimeoutMillis()); + } + + @Test + void create_usesDefaultTimeoutWhenNull() { + OkHttpClient client = OciOkHttpClientFactory.create( + authProvider, null, null, null, false); + + assertEquals(120_000, client.connectTimeoutMillis()); + assertEquals(120_000, client.readTimeoutMillis()); + assertEquals(120_000, client.writeTimeoutMillis()); + } + + @Test + void create_throwsOnNullAuthProvider() { + assertThrows(NullPointerException.class, () -> + OciOkHttpClientFactory.create(null, null)); + } +} 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 new file mode 100644 index 0000000..9cbee50 --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OciSigningInterceptorTest.java @@ -0,0 +1,133 @@ +/* + * 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.oracle.bmc.auth.BasicAuthenticationDetailsProvider; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OciSigningInterceptorTest { + + private MockWebServer server; + private OkHttpClient client; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + + // Generate a real RSA key pair for OCI SDK signing + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + + // PEM-encode the private key (PKCS#8 format) so getPrivateKey() returns an InputStream + String pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString( + keyPair.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----"; + + // Mock the OCI auth provider with real key material + BasicAuthenticationDetailsProvider authProvider = + mock(BasicAuthenticationDetailsProvider.class); + when(authProvider.getKeyId()).thenReturn( + "ocid1.tenancy.oc1..test/ocid1.user.oc1..test/aa:bb:cc:dd"); + when(authProvider.getPrivateKey()).thenAnswer( + inv -> new ByteArrayInputStream(pem.getBytes())); + + client = new OkHttpClient.Builder() + .addInterceptor(new OciSigningInterceptor(authProvider)) + .build(); + } + + @AfterEach + void tearDown() throws IOException { + server.shutdown(); + } + + @Test + void addsAuthorizationHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + client.newCall(new Request.Builder() + .url(server.url("/test")) + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + String auth = request.getHeader("Authorization"); + assertNotNull(auth, "Authorization header should be present"); + assertTrue(auth.startsWith("Signature"), "Should be OCI signature format"); + } + + @Test + void addsDateHeader() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + client.newCall(new Request.Builder() + .url(server.url("/test")) + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + assertNotNull(request.getHeader("date"), "date header should be present"); + } + + @Test + void signsPostBodyWithContentHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + RequestBody body = RequestBody.create( + "{\"message\":\"hello\"}", MediaType.parse("application/json")); + + client.newCall(new Request.Builder() + .url(server.url("/test")) + .post(body) + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + assertNotNull(request.getHeader("Authorization"), "Authorization should be present"); + assertNotNull(request.getHeader("x-content-sha256"), "x-content-sha256 should be present for POST"); + assertEquals("{\"message\":\"hello\"}", request.getBody().readUtf8(), + "Body should be preserved after signing"); + } + + @Test + void preservesOriginalHeaders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + client.newCall(new Request.Builder() + .url(server.url("/test")) + .header("X-Custom-Header", "custom-value") + .build()).execute().close(); + + RecordedRequest request = server.takeRequest(); + assertEquals("custom-value", request.getHeader("X-Custom-Header")); + assertNotNull(request.getHeader("Authorization")); + } + + @Test + void throwsOnNullAuthProvider() { + assertThrows(NullPointerException.class, () -> + new OciSigningInterceptor(null)); + } +} diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAIIntegrationTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAIIntegrationTest.java new file mode 100644 index 0000000..5f59e44 --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAIIntegrationTest.java @@ -0,0 +1,241 @@ +/* + * 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.core.RequestOptions; +import com.openai.core.http.HttpClient; +import com.openai.core.http.HttpRequest; +import com.openai.core.http.HttpRequestBody; +import com.openai.core.http.HttpResponse; +import com.openai.core.http.Headers; +import com.openai.models.chat.completions.ChatCompletion; +import com.openai.models.chat.completions.ChatCompletionCreateParams; +import okhttp3.*; +import okio.BufferedSink; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test: OCI auth library + OpenAI Java SDK against PPE endpoint. + * + *

To run: + *

+ * oci session authenticate
+ * mvn -pl oci-genai-auth-java-core test -Dtest=OpenAIIntegrationTest
+ * 
+ */ +class OpenAIIntegrationTest { + + private static final String COMPARTMENT_ID = + System.getenv().getOrDefault("OCI_COMPARTMENT_ID", ""); + + private static final String BASE_URL = + System.getenv().getOrDefault("OCI_GENAI_ENDPOINT", + "https://ppe.inference.generativeai.us-chicago-1.oci.oraclecloud.com") + + "/20231130/actions/v1"; + + @Test + @Disabled("Requires live OCI session — run: oci session authenticate") + void openai_via_oci_auth_library() { + // 1. Build OCI-signed OkHttpClient + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .compartmentId(COMPARTMENT_ID) + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // 2. Wrap in OpenAI HttpClient adapter and build client + HttpClient signingHttpClient = new OpenAIOkHttpAdapter(ociHttpClient, BASE_URL); + + ClientOptions clientOptions = ClientOptions.builder() + .httpClient(signingHttpClient) + .baseUrl(BASE_URL) + .apiKey("OCI_AUTH") + .build(); + + OpenAIClient client = new OpenAIClientImpl(clientOptions); + + try { + // 3. Send a request + ChatCompletion completion = client.chat().completions().create( + ChatCompletionCreateParams.builder() + .model("xai.grok-3") + .addUserMessage("What is 2 + 2? Answer in one word.") + .build()); + + // 4. Verify response + assertNotNull(completion, "Response should not be null"); + assertFalse(completion.choices().isEmpty(), "Should have choices"); + + completion.choices().forEach(choice -> + choice.message().content().ifPresent(content -> { + System.out.println("OpenAI response: " + content); + assertFalse(content.isBlank(), "Response content should not be blank"); + })); + } finally { + client.close(); + } + } + + /** + * Minimal adapter: bridges OpenAI SDK's HttpClient to OCI-signed OkHttpClient. + * The OpenAI SDK uses pathSegments instead of full URLs, so we need baseUrl. + */ + private static class OpenAIOkHttpAdapter implements HttpClient { + + private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json"); + private final OkHttpClient okHttpClient; + private final HttpUrl baseUrl; + + OpenAIOkHttpAdapter(OkHttpClient okHttpClient, String baseUrl) { + this.okHttpClient = okHttpClient; + this.baseUrl = HttpUrl.parse(baseUrl); + } + + @Override + public HttpResponse execute(HttpRequest request, RequestOptions requestOptions) { + Request okRequest = toOkHttpRequest(request); + try { + Response okResponse = okHttpClient.newCall(okRequest).execute(); + return new OkHttpResponseAdapter(okResponse); + } catch (IOException e) { + throw new RuntimeException("OCI request failed: " + request.url(), e); + } + } + + @Override + public CompletableFuture executeAsync( + HttpRequest request, RequestOptions requestOptions) { + Request okRequest = toOkHttpRequest(request); + CompletableFuture future = new CompletableFuture<>(); + okHttpClient.newCall(okRequest).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + future.completeExceptionally(e); + } + + @Override + public void onResponse(Call call, Response response) { + future.complete(new OkHttpResponseAdapter(response)); + } + }); + return future; + } + + @Override + public void close() { + okHttpClient.dispatcher().executorService().shutdown(); + okHttpClient.connectionPool().evictAll(); + } + + private Request toOkHttpRequest(HttpRequest request) { + HttpUrl.Builder urlBuilder; + String url = request.url(); + if (url != null && !url.isBlank()) { + HttpUrl parsedUrl = HttpUrl.parse(url); + if (parsedUrl == null) throw new IllegalArgumentException("Invalid URL: " + url); + urlBuilder = parsedUrl.newBuilder(); + } else { + urlBuilder = baseUrl.newBuilder(); + List pathSegments = request.pathSegments(); + if (pathSegments != null) { + for (String segment : pathSegments) { + urlBuilder.addPathSegment(segment); + } + } + } + + var queryParams = request.queryParams(); + for (String key : queryParams.keys()) { + for (String value : queryParams.values(key)) { + urlBuilder.addQueryParameter(key, value); + } + } + + okhttp3.Headers.Builder headersBuilder = new okhttp3.Headers.Builder(); + var headers = request.headers(); + for (String name : headers.names()) { + for (String value : headers.values(name)) { + headersBuilder.add(name, value); + } + } + + RequestBody body = null; + HttpRequestBody requestBody = request.body(); + if (requestBody != null) { + body = new RequestBody() { + @Override + public MediaType contentType() { + String ct = requestBody.contentType(); + return ct != null ? MediaType.parse(ct) : JSON_MEDIA_TYPE; + } + + @Override + public long contentLength() { + return requestBody.contentLength(); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + try (OutputStream os = sink.outputStream()) { + requestBody.writeTo(os); + } + } + }; + } + + return new Request.Builder() + .url(urlBuilder.build()) + .headers(headersBuilder.build()) + .method(request.method().name(), body) + .build(); + } + + private static class OkHttpResponseAdapter implements HttpResponse { + private final Response response; + private final Headers headers; + + OkHttpResponseAdapter(Response response) { + this.response = response; + Headers.Builder builder = Headers.builder(); + for (String name : response.headers().names()) { + for (String value : response.headers(name)) { + builder.put(name, value); + } + } + this.headers = builder.build(); + } + + @Override + public int statusCode() { return response.code(); } + + @Override + public Headers headers() { return headers; } + + @Override + public InputStream body() { + ResponseBody b = response.body(); + return b != null ? b.byteStream() : InputStream.nullInputStream(); + } + + @Override + public void close() { response.close(); } + } + } +} diff --git a/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAIResponsesIntegrationTest.java b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAIResponsesIntegrationTest.java new file mode 100644 index 0000000..b79f8b5 --- /dev/null +++ b/oci-genai-auth-java-core/src/test/java/com/oracle/genai/auth/OpenAIResponsesIntegrationTest.java @@ -0,0 +1,253 @@ +/* + * 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.core.RequestOptions; +import com.openai.core.http.HttpClient; +import com.openai.core.http.HttpRequest; +import com.openai.core.http.HttpRequestBody; +import com.openai.core.http.HttpResponse; +import com.openai.core.http.Headers; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputItem; +import okhttp3.*; +import okio.BufferedSink; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test: OCI auth library + OpenAI Responses API against PPE endpoint. + * + *

Tests the Responses API (newer replacement for Chat Completions) which supports + * compaction for long-running conversations. + * + *

To run: + *

+ * oci session authenticate
+ * mvn -pl oci-genai-auth-java-core test -Dtest=OpenAIResponsesIntegrationTest
+ * 
+ */ +class OpenAIResponsesIntegrationTest { + + private static final String COMPARTMENT_ID = + System.getenv().getOrDefault("OCI_COMPARTMENT_ID", ""); + + private static final String BASE_URL = + System.getenv().getOrDefault("OCI_GENAI_ENDPOINT", + "https://ppe.inference.generativeai.us-chicago-1.oci.oraclecloud.com") + + "/20231130/actions/v1"; + + @Test + @Disabled("Requires live OCI session — run: oci session authenticate") + void responses_api_via_oci_auth_library() { + // 1. Build OCI-signed OkHttpClient + OciAuthConfig config = OciAuthConfig.builder() + .authType("security_token") + .profile("DEFAULT") + .compartmentId(COMPARTMENT_ID) + .build(); + + OkHttpClient ociHttpClient = OciOkHttpClientFactory.build(config); + + // 2. Wrap in OpenAI HttpClient adapter and build client + HttpClient signingHttpClient = new OpenAIOkHttpAdapter(ociHttpClient, BASE_URL); + + ClientOptions clientOptions = ClientOptions.builder() + .httpClient(signingHttpClient) + .baseUrl(BASE_URL) + .apiKey("OCI_AUTH") + .build(); + + OpenAIClient client = new OpenAIClientImpl(clientOptions); + + try { + // 3. Send a Responses API request + ResponseCreateParams params = ResponseCreateParams.builder() + .model("xai.grok-3") + .input("What is 2 + 2? Answer in one word.") + .build(); + + Response response = client.responses().create(params); + + // 4. Verify response + assertNotNull(response, "Response should not be null"); + assertNotNull(response.output(), "Output should not be null"); + assertFalse(response.output().isEmpty(), "Output should not be empty"); + + // 5. Extract text from output + for (ResponseOutputItem item : response.output()) { + item.message().ifPresent(message -> { + for (var content : message.content()) { + content.outputText().ifPresent(text -> { + System.out.println("Responses API output: " + text.text()); + assertFalse(text.text().isBlank(), "Response text should not be blank"); + }); + } + }); + } + } finally { + client.close(); + } + } + + /** + * Minimal adapter: bridges OpenAI SDK's HttpClient to OCI-signed OkHttpClient. + * The OpenAI SDK uses pathSegments instead of full URLs, so we need baseUrl. + */ + private static class OpenAIOkHttpAdapter implements HttpClient { + + private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json"); + private final OkHttpClient okHttpClient; + private final HttpUrl baseUrl; + + OpenAIOkHttpAdapter(OkHttpClient okHttpClient, String baseUrl) { + this.okHttpClient = okHttpClient; + this.baseUrl = HttpUrl.parse(baseUrl); + } + + @Override + public HttpResponse execute(HttpRequest request, RequestOptions requestOptions) { + Request okRequest = toOkHttpRequest(request); + try { + okhttp3.Response okResponse = okHttpClient.newCall(okRequest).execute(); + return new OkHttpResponseAdapter(okResponse); + } catch (IOException e) { + throw new RuntimeException("OCI request failed: " + request.url(), e); + } + } + + @Override + public CompletableFuture executeAsync( + HttpRequest request, RequestOptions requestOptions) { + Request okRequest = toOkHttpRequest(request); + CompletableFuture future = new CompletableFuture<>(); + okHttpClient.newCall(okRequest).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + future.completeExceptionally(e); + } + + @Override + public void onResponse(Call call, okhttp3.Response response) { + future.complete(new OkHttpResponseAdapter(response)); + } + }); + return future; + } + + @Override + public void close() { + okHttpClient.dispatcher().executorService().shutdown(); + okHttpClient.connectionPool().evictAll(); + } + + private Request toOkHttpRequest(HttpRequest request) { + HttpUrl.Builder urlBuilder; + String url = request.url(); + if (url != null && !url.isBlank()) { + HttpUrl parsedUrl = HttpUrl.parse(url); + if (parsedUrl == null) throw new IllegalArgumentException("Invalid URL: " + url); + urlBuilder = parsedUrl.newBuilder(); + } else { + urlBuilder = baseUrl.newBuilder(); + List pathSegments = request.pathSegments(); + if (pathSegments != null) { + for (String segment : pathSegments) { + urlBuilder.addPathSegment(segment); + } + } + } + + var queryParams = request.queryParams(); + for (String key : queryParams.keys()) { + for (String value : queryParams.values(key)) { + urlBuilder.addQueryParameter(key, value); + } + } + + okhttp3.Headers.Builder headersBuilder = new okhttp3.Headers.Builder(); + var headers = request.headers(); + for (String name : headers.names()) { + for (String value : headers.values(name)) { + headersBuilder.add(name, value); + } + } + + RequestBody body = null; + HttpRequestBody requestBody = request.body(); + if (requestBody != null) { + body = new RequestBody() { + @Override + public MediaType contentType() { + String ct = requestBody.contentType(); + return ct != null ? MediaType.parse(ct) : JSON_MEDIA_TYPE; + } + + @Override + public long contentLength() { + return requestBody.contentLength(); + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + try (OutputStream os = sink.outputStream()) { + requestBody.writeTo(os); + } + } + }; + } + + return new Request.Builder() + .url(urlBuilder.build()) + .headers(headersBuilder.build()) + .method(request.method().name(), body) + .build(); + } + + private static class OkHttpResponseAdapter implements HttpResponse { + private final okhttp3.Response response; + private final Headers headers; + + OkHttpResponseAdapter(okhttp3.Response response) { + this.response = response; + Headers.Builder builder = Headers.builder(); + for (String name : response.headers().names()) { + for (String value : response.headers(name)) { + builder.put(name, value); + } + } + this.headers = builder.build(); + } + + @Override + public int statusCode() { return response.code(); } + + @Override + public Headers headers() { return headers; } + + @Override + public InputStream body() { + ResponseBody b = response.body(); + return b != null ? b.byteStream() : InputStream.nullInputStream(); + } + + @Override + public void close() { response.close(); } + } + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4a4a50d --- /dev/null +++ b/pom.xml @@ -0,0 +1,131 @@ + + + + 4.0.0 + + com.oracle.genai + oci-genai-auth-java-parent + 0.1.0-SNAPSHOT + pom + + OCI GenAI Auth :: Parent + + OCI request signing and authentication utilities for calling OCI-hosted + AI compatibility endpoints using any HTTP client based on OkHttp. + + + + + Universal Permissive License v 1.0 + https://oss.oracle.com/licenses/upl/ + + + + + oci-genai-auth-java-bom + oci-genai-auth-java-core + + + + UTF-8 + 17 + 17 + + + 0.1.0-SNAPSHOT + + + 3.72.1 + + + 5.3.2 + 2.0.17 + + + 5.11.4 + 5.14.2 + + + 3.13.0 + 3.5.2 + 3.4.2 + + + + + + + com.oracle.genai + oci-genai-auth-java-bom + ${oci-genai-auth.version} + pom + import + + + + + + + + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + com.squareup.okhttp3 + mockwebserver + ${okhttp.version} + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + +