diff --git a/.fernignore b/.fernignore index 16abecd79..8fcc59fed 100644 --- a/.fernignore +++ b/.fernignore @@ -15,6 +15,7 @@ src/cohere/manually_maintained/__init__.py src/cohere/bedrock_client.py src/cohere/aws_client.py src/cohere/sagemaker_client.py +src/cohere/oci_client.py src/cohere/client_v2.py mypy.ini src/cohere/aliases.py \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04552f43..8d27ce624 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,11 @@ jobs: echo "Current cohere installation: $(poetry show cohere)" make test - make integration_test + + # Skip test_langchain_tool_calling_agent: uses deprecated models and hits + # the live API without temperature=0, causing non-deterministic failures. + # See https://github.com/cohere-ai/cohere-python/pull/738 + poetry run pytest tests/integration_tests/ --deselect tests/integration_tests/test_langgraph_agents.py::test_langchain_tool_calling_agent echo "tests passed" # reset poetry changes diff --git a/README.md b/README.md index c474bb632..1a2931222 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,111 @@ for event in response: print(event.delta.message.content.text, end="") ``` +## Oracle Cloud Infrastructure (OCI) + +The SDK supports Oracle Cloud Infrastructure (OCI) Generative AI service. First, install the OCI SDK: + +``` +pip install 'cohere[oci]' +``` + +Then use `OciClientV2`: + +```Python +import cohere + +# Using OCI config file authentication (default: ~/.oci/config) +co = cohere.OciClientV2( + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", +) + +response = co.embed( + model="embed-english-v3.0", + texts=["Hello world"], + input_type="search_document", +) + +print(response.embeddings) +``` + +### OCI Authentication Methods + +**1. Config File (Default)** +```Python +co = cohere.OciClientV2( + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", + # Uses ~/.oci/config with DEFAULT profile +) +``` + +**2. Custom Profile** +```Python +co = cohere.OciClientV2( + oci_profile="MY_PROFILE", + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", +) +``` + +**3. Session-based Authentication (Security Token)** +```Python +# Works with OCI CLI session tokens +co = cohere.OciClientV2( + oci_profile="MY_SESSION_PROFILE", # Profile with security_token_file + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", +) +``` + +**4. Direct Credentials** +```Python +co = cohere.OciClientV2( + oci_user_id="ocid1.user.oc1...", + oci_fingerprint="xx:xx:xx:...", + oci_tenancy_id="ocid1.tenancy.oc1...", + oci_private_key_path="~/.oci/key.pem", + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", +) +``` + +**5. Instance Principal (for OCI Compute instances)** +```Python +co = cohere.OciClientV2( + auth_type="instance_principal", + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", +) +``` + +### Supported OCI APIs + +The OCI client supports the following Cohere APIs: +- **Embed**: Full support for all embedding models (embed-english-v3.0, embed-english-light-v3.0, embed-multilingual-v3.0) +- **Chat**: Full support with `OciClientV2` + - Streaming available via `chat_stream()` + - Supports Command-R and Command-A model families + +### OCI Model Availability and Limitations + +**Available on OCI On-Demand Inference:** +- **Embed models**: embed-english-v3.0, embed-english-light-v3.0, embed-multilingual-v3.0 +- **Chat models**: command-r-08-2024, command-r-plus, command-a-03-2025 + +**Not Available on OCI On-Demand Inference:** +- **Generate API**: OCI TEXT_GENERATION models are base models that require fine-tuning before deployment +- **Rerank API**: OCI TEXT_RERANK models are base models that require fine-tuning before deployment +- **Multiple Embedding Types**: OCI on-demand models only support single embedding type per request (cannot request both `float` and `int8` simultaneously) + +**Note**: To use Generate or Rerank models on OCI, you need to: +1. Fine-tune the base model using OCI's fine-tuning service +2. Deploy the fine-tuned model to a dedicated endpoint +3. Update your code to use the deployed model endpoint + +For the latest model availability, see the [OCI Generative AI documentation](https://docs.oracle.com/en-us/iaas/Content/generative-ai/home.htm). + ## Contributing While we value open-source contributions to this SDK, the code is generated programmatically. Additions made directly would have to be moved over to our generation code, otherwise they would be overwritten upon the next generated release. Feel free to open a PR as a proof of concept, but know that we will not be able to merge it as-is. We suggest opening an issue first to discuss with us! diff --git a/poetry.lock b/poetry.lock index b77572c90..72116815f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -52,6 +52,102 @@ files = [ {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = true +python-versions = ">=3.9" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -174,6 +270,17 @@ files = [ {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] +[[package]] +name = "circuitbreaker" +version = "2.1.3" +description = "Python Circuit Breaker pattern implementation" +optional = true +python-versions = "*" +files = [ + {file = "circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1"}, + {file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"}, +] + [[package]] name = "click" version = "8.1.8" @@ -213,6 +320,55 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = true +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -604,6 +760,28 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "oci" +version = "2.168.1" +description = "Oracle Cloud Infrastructure Python SDK" +optional = true +python-versions = "*" +files = [ + {file = "oci-2.168.1-py3-none-any.whl", hash = "sha256:d106cfffc9153b5c9de628877c967ed87bbbfbbc9d411c97feee0eba8f2e4eab"}, + {file = "oci-2.168.1.tar.gz", hash = "sha256:b941674171b41e999b8e3adb38d4797d7b42d2bb5ff40d17c26e8ce2a7d4b605"}, +] + +[package.dependencies] +certifi = "*" +circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""} +cryptography = ">=3.2.1,<47.0.0" +pyOpenSSL = ">=17.5.0,<=25.3.0" +python-dateutil = ">=2.5.3,<3.0.0" +pytz = ">=2016.10" + +[package.extras] +adk = ["docstring-parser (>=0.16)", "mcp (>=1.6.0)", "pydantic (>=2.10.6)", "rich (>=13.9.4)"] + [[package]] name = "packaging" version = "26.0" @@ -630,6 +808,17 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -798,6 +987,25 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyopenssl" +version = "25.1.0" +description = "Python wrapper module around the OpenSSL library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab"}, + {file = "pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b"}, +] + +[package.dependencies] +cryptography = ">=41.0.5,<46" +typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] + [[package]] name = "pytest" version = "7.4.4" @@ -872,6 +1080,17 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "pytz" +version = "2026.1.post1" +description = "World timezone definitions, modern and historical" +optional = true +python-versions = "*" +files = [ + {file = "pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a"}, + {file = "pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1"}, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1261,7 +1480,10 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0)"] +[extras] +oci = ["oci"] + [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "384b8db70da6095b11f9b3afddf8ff33e82021430de0a6d644dc1ed6e691c259" +content-hash = "1bae1f982ce9f747db1399f863f7b02ad784137612f52bf6aeacf527abfd9e20" diff --git a/pyproject.toml b/pyproject.toml index b991fc2ef..78e0aa920 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,10 @@ requests = "^2.0.0" tokenizers = ">=0.15,<1" types-requests = "^2.0.0" typing_extensions = ">= 4.0.0" +oci = { version = "^2.165.0", optional = true } + +[tool.poetry.extras] +oci = ["oci"] [tool.poetry.group.dev.dependencies] mypy = "==1.13.0" diff --git a/src/cohere/__init__.py b/src/cohere/__init__.py index 5a9a38251..79cc85535 100644 --- a/src/cohere/__init__.py +++ b/src/cohere/__init__.py @@ -518,6 +518,7 @@ "NotFoundError": ".errors", "NotImplementedError": ".errors", "OAuthAuthorizeResponse": ".types", + "OciClientV2": ".oci_client", "ParseInfo": ".types", "RerankDocument": ".types", "RerankRequestDocumentsItem": ".types", @@ -851,6 +852,7 @@ def __dir__(): "NotFoundError", "NotImplementedError", "OAuthAuthorizeResponse", + "OciClientV2", "ParseInfo", "RerankDocument", "RerankRequestDocumentsItem", diff --git a/src/cohere/manually_maintained/lazy_oci_deps.py b/src/cohere/manually_maintained/lazy_oci_deps.py new file mode 100644 index 000000000..3ff711724 --- /dev/null +++ b/src/cohere/manually_maintained/lazy_oci_deps.py @@ -0,0 +1,30 @@ +"""Lazy loading for optional OCI SDK dependency.""" + +from typing import Any + +OCI_INSTALLATION_MESSAGE = """ +The OCI SDK is required to use OciClientV2. + +Install it with: + pip install oci + +Or with the optional dependency group: + pip install cohere[oci] +""" + + +def lazy_oci() -> Any: + """ + Lazily import the OCI SDK. + + Returns: + The oci module + + Raises: + ImportError: If the OCI SDK is not installed + """ + try: + import oci # type: ignore + return oci + except ImportError: + raise ImportError(OCI_INSTALLATION_MESSAGE) diff --git a/src/cohere/oci_client.py b/src/cohere/oci_client.py new file mode 100644 index 000000000..ddeeb3727 --- /dev/null +++ b/src/cohere/oci_client.py @@ -0,0 +1,904 @@ +"""Oracle Cloud Infrastructure (OCI) client for Cohere API.""" + +import email.utils +import json +import os +import typing +import uuid + +import httpx +import requests +from .client import ClientEnvironment +from .client_v2 import ClientV2 +from .manually_maintained.lazy_oci_deps import lazy_oci +from httpx import URL, ByteStream, SyncByteStream + + +class OciClientV2(ClientV2): + """ + Cohere V2 API client for Oracle Cloud Infrastructure (OCI) Generative AI service. + + Supported APIs on OCI: + - embed(): Full support for all embedding models (returns embeddings as dict) + - chat(): Full support with Command-A models (command-a-03-2025) + - chat_stream(): Streaming chat with proper V2 event format + + Note: rerank() requires fine-tuned models deployed to dedicated endpoints. + OCI on-demand inference does not support the rerank API. + + Supports all authentication methods: + - Config file (default): Uses ~/.oci/config + - Session-based: Uses OCI CLI session tokens + - Direct credentials: Pass OCI credentials directly + - Instance principal: For OCI compute instances + - Resource principal: For OCI functions + + Example using config file: + ```python + import cohere + + client = cohere.OciClientV2( + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", + ) + + # V2 embed returns embeddings as dict with type keys + response = client.embed( + model="embed-english-v3.0", + texts=["Hello world"], + input_type="search_document", + ) + print(response.embeddings.float_) # Access float embeddings + + # V2 chat with Command-A models + response = client.chat( + model="command-a-03-2025", + messages=[{"role": "user", "content": "Hello!"}], + ) + print(response.message) + ``` + + Example using direct credentials: + ```python + client = cohere.OciClientV2( + oci_user_id="ocid1.user.oc1...", + oci_fingerprint="xx:xx:xx:...", + oci_tenancy_id="ocid1.tenancy.oc1...", + oci_private_key_path="~/.oci/key.pem", + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", + ) + ``` + + Example using instance principal: + ```python + client = cohere.OciClientV2( + auth_type="instance_principal", + oci_region="us-chicago-1", + oci_compartment_id="ocid1.compartment.oc1...", + ) + ``` + """ + + def __init__( + self, + *, + # Authentication - Config file (default) + oci_config_path: typing.Optional[str] = None, + oci_profile: typing.Optional[str] = None, + # Authentication - Direct credentials + oci_user_id: typing.Optional[str] = None, + oci_fingerprint: typing.Optional[str] = None, + oci_tenancy_id: typing.Optional[str] = None, + oci_private_key_path: typing.Optional[str] = None, + oci_private_key_content: typing.Optional[str] = None, + # Authentication - Instance principal + auth_type: typing.Literal["api_key", "instance_principal", "resource_principal"] = "api_key", + # Required for OCI Generative AI + oci_region: typing.Optional[str] = None, + oci_compartment_id: str, + # Standard parameters + timeout: typing.Optional[float] = None, + ): + # Load OCI config based on auth_type + oci_config = _load_oci_config( + auth_type=auth_type, + config_path=oci_config_path, + profile=oci_profile, + user_id=oci_user_id, + fingerprint=oci_fingerprint, + tenancy_id=oci_tenancy_id, + private_key_path=oci_private_key_path, + private_key_content=oci_private_key_content, + ) + + # Get region from config if not provided + if oci_region is None: + oci_region = oci_config.get("region") + if oci_region is None: + raise ValueError("oci_region must be provided either directly or in OCI config file") + + # Create httpx client with OCI event hooks + ClientV2.__init__( + self, + base_url="https://api.cohere.com", # Unused, OCI URL set in hooks + environment=ClientEnvironment.PRODUCTION, + client_name="n/a", + timeout=timeout, + api_key="n/a", + httpx_client=httpx.Client( + event_hooks=get_event_hooks( + oci_config=oci_config, + oci_region=oci_region, + oci_compartment_id=oci_compartment_id, + ), + timeout=timeout, + ), + ) + + +EventHook = typing.Callable[..., typing.Any] + + +class Streamer(SyncByteStream): + """Wraps an iterator of bytes for streaming responses.""" + + lines: typing.Iterator[bytes] + + def __init__(self, lines: typing.Iterator[bytes]): + self.lines = lines + + def __iter__(self) -> typing.Iterator[bytes]: + return self.lines + + +def _load_oci_config( + auth_type: str, + config_path: typing.Optional[str], + profile: typing.Optional[str], + **kwargs: typing.Any, +) -> typing.Dict[str, typing.Any]: + """ + Load OCI configuration based on authentication type. + + Args: + auth_type: Authentication method (api_key, instance_principal, resource_principal) + config_path: Path to OCI config file (for api_key auth) + profile: Profile name in config file (for api_key auth) + **kwargs: Direct credentials (user_id, fingerprint, etc.) + + Returns: + Dictionary containing OCI configuration + """ + oci = lazy_oci() + + if auth_type == "instance_principal": + signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + return {"signer": signer, "auth_type": "instance_principal"} + + elif auth_type == "resource_principal": + signer = oci.auth.signers.get_resource_principals_signer() + return {"signer": signer, "auth_type": "resource_principal"} + + elif kwargs.get("user_id"): + # Direct credentials provided - validate required fields + required_fields = ["fingerprint", "tenancy_id"] + missing = [f for f in required_fields if not kwargs.get(f)] + if missing: + raise ValueError( + f"When providing oci_user_id, you must also provide: {', '.join('oci_' + f for f in missing)}" + ) + if not kwargs.get("private_key_path") and not kwargs.get("private_key_content"): + raise ValueError( + "When providing oci_user_id, you must also provide either " + "oci_private_key_path or oci_private_key_content" + ) + config = { + "user": kwargs["user_id"], + "fingerprint": kwargs["fingerprint"], + "tenancy": kwargs["tenancy_id"], + } + if kwargs.get("private_key_path"): + config["key_file"] = kwargs["private_key_path"] + if kwargs.get("private_key_content"): + config["key_content"] = kwargs["private_key_content"] + return config + + else: + # Load from config file + return oci.config.from_file( + file_location=config_path or "~/.oci/config", profile_name=profile or "DEFAULT" + ) + + +def get_event_hooks( + oci_config: typing.Dict[str, typing.Any], + oci_region: str, + oci_compartment_id: str, +) -> typing.Dict[str, typing.List[EventHook]]: + """ + Create httpx event hooks for OCI request/response transformation. + + Args: + oci_config: OCI configuration dictionary + oci_region: OCI region (e.g., "us-chicago-1") + oci_compartment_id: OCI compartment OCID + + Returns: + Dictionary of event hooks for httpx + """ + return { + "request": [ + map_request_to_oci( + oci_config=oci_config, + oci_region=oci_region, + oci_compartment_id=oci_compartment_id, + ), + ], + "response": [map_response_from_oci()], + } + + +def map_request_to_oci( + oci_config: typing.Dict[str, typing.Any], + oci_region: str, + oci_compartment_id: str, +) -> EventHook: + """ + Create event hook that transforms Cohere V2 requests to OCI format and signs them. + + Args: + oci_config: OCI configuration dictionary + oci_region: OCI region + oci_compartment_id: OCI compartment OCID + + Returns: + Event hook function for httpx + """ + oci = lazy_oci() + + # Create OCI signer based on config type + # Priority order: instance/resource principal > API key auth > session-based auth + if "signer" in oci_config: + signer = oci_config["signer"] # Instance/resource principal + elif "user" in oci_config: + # Config has user field - standard API key auth (prioritize this over session-based) + signer = oci.signer.Signer( + tenancy=oci_config["tenancy"], + user=oci_config["user"], + fingerprint=oci_config["fingerprint"], + private_key_file_location=oci_config.get("key_file"), + private_key_content=oci_config.get("key_content"), + ) + elif "security_token_file" in oci_config: + # Session-based authentication with security token (fallback if no user field) + # Note: Session tokens expire (typically ~1 hour). If expired, re-run: + # oci session authenticate --profile + token_file_path = os.path.expanduser(oci_config["security_token_file"]) + try: + with open(token_file_path, "r") as f: + security_token = f.read().strip() + except FileNotFoundError: + raise ValueError( + f"OCI session token file not found: {token_file_path}. " + "Your session may have expired. Re-authenticate with: " + "oci session authenticate" + ) + + # Load private key using OCI's utility function + key_file = oci_config.get("key_file") + if not key_file: + raise ValueError( + "OCI config profile is missing 'key_file'. " + "Session-based auth requires a key_file entry in your OCI config profile." + ) + private_key = oci.signer.load_private_key_from_file(key_file) + + signer = oci.auth.signers.SecurityTokenSigner( + token=security_token, + private_key=private_key, + ) + else: + # Config doesn't have user or security token - unsupported + raise ValueError( + "OCI config is missing 'user' field and no security_token_file found. " + "Please use a profile with standard API key authentication, " + "session-based authentication, or provide direct credentials via oci_user_id parameter." + ) + + def _event_hook(request: httpx.Request) -> None: + # Extract Cohere API details + path_parts = request.url.path.split("/") + endpoint = path_parts[-1] + try: + body = json.loads(request.read()) + except json.JSONDecodeError as e: + raise RuntimeError(f"OCI client: failed to parse request body as JSON: {e}") from e + + # Build OCI URL + url = get_oci_url( + region=oci_region, + endpoint=endpoint, + stream="stream" in endpoint or body.get("stream", False), + ) + + # Transform request body to OCI format + try: + oci_body = transform_request_to_oci( + endpoint=endpoint, + cohere_body=body, + compartment_id=oci_compartment_id, + ) + except (KeyError, ValueError) as e: + raise RuntimeError(f"OCI client: failed to transform request for endpoint '{endpoint}': {e}") from e + + # Prepare request for signing + oci_body_bytes = json.dumps(oci_body).encode("utf-8") + + # Build headers for signing + headers = { + "content-type": "application/json", + "date": email.utils.formatdate(usegmt=True), + } + + # Create a requests.PreparedRequest for OCI signing + oci_request = requests.Request( + method=request.method, + url=url, + headers=headers, + data=oci_body_bytes, + ) + prepped_request = oci_request.prepare() + + # Sign the request using OCI signer (modifies headers in place) + try: + signer.do_request_sign(prepped_request) + except Exception as e: + raise RuntimeError(f"OCI client: request signing failed for endpoint '{endpoint}': {e}") from e + + # Update httpx request with signed headers + request.url = URL(url) + request.headers = httpx.Headers(prepped_request.headers) + request.stream = ByteStream(oci_body_bytes) + request._content = oci_body_bytes + request.extensions["endpoint"] = endpoint + request.extensions["cohere_body"] = body + request.extensions["is_stream"] = "stream" in endpoint or body.get("stream", False) + + return _event_hook + + +def map_response_from_oci() -> EventHook: + """ + Create event hook that transforms OCI responses to Cohere V2 format. + + Returns: + Event hook function for httpx + """ + + def _hook(response: httpx.Response) -> None: + endpoint = response.request.extensions.get("endpoint") + if endpoint is None: + return # Request hook didn't run; pass response through unchanged + is_stream = response.request.extensions.get("is_stream", False) + + output: typing.Iterator[bytes] + + # Only transform successful responses (200-299) + # Let error responses pass through unchanged so SDK error handling works + if not (200 <= response.status_code < 300): + return + + # For streaming responses, wrap the stream with a transformer + if is_stream: + original_stream = response.stream + transformed_stream = transform_oci_stream_wrapper(typing.cast(SyncByteStream, original_stream), endpoint) + response.stream = Streamer(transformed_stream) + # Reset consumption flags + if hasattr(response, "_content"): + del response._content + response.is_stream_consumed = False + response.is_closed = False + return + + # Handle non-streaming responses + oci_response = json.loads(response.read()) + cohere_response = transform_oci_response_to_cohere(endpoint, oci_response) + output = iter([json.dumps(cohere_response).encode("utf-8")]) + + response.stream = Streamer(output) + + # Reset response for re-reading + if hasattr(response, "_content"): + del response._content + response.is_stream_consumed = False + response.is_closed = False + + return _hook + + +def get_oci_url( + region: str, + endpoint: str, + stream: bool = False, +) -> str: + """ + Map Cohere endpoints to OCI Generative AI endpoints. + + Args: + region: OCI region (e.g., "us-chicago-1") + endpoint: Cohere endpoint name + stream: Whether this is a streaming request + + Returns: + Full OCI Generative AI endpoint URL + """ + base = f"https://inference.generativeai.{region}.oci.oraclecloud.com" + api_version = "20231130" + + # Map Cohere endpoints to OCI actions + action_map = { + "embed": "embedText", + "chat": "chat", + "chat_stream": "chat", + "rerank": "rerankText", + } + + action = action_map.get(endpoint) + if action is None: + raise ValueError( + f"Endpoint '{endpoint}' is not supported by OCI Generative AI. " + f"Supported endpoints: {list(action_map.keys())}" + ) + return f"{base}/{api_version}/actions/{action}" + + +def normalize_model_for_oci(model: str) -> str: + """ + Normalize model name for OCI. + + OCI accepts model names in the format "cohere.model-name" or full OCIDs. + This function ensures proper formatting for all regions. + + Args: + model: Model name (e.g., "command-r-08-2024") or full OCID + + Returns: + Normalized model identifier (e.g., "cohere.command-r-08-2024" or OCID) + + Examples: + >>> normalize_model_for_oci("command-a-03-2025") + "cohere.command-a-03-2025" + >>> normalize_model_for_oci("cohere.embed-english-v3.0") + "cohere.embed-english-v3.0" + >>> normalize_model_for_oci("ocid1.generativeaimodel.oc1...") + "ocid1.generativeaimodel.oc1..." + """ + # If it's already an OCID, return as-is (works across all regions) + if model.startswith("ocid1."): + return model + + # Add "cohere." prefix if not present + if not model.startswith("cohere."): + return f"cohere.{model}" + + return model + + +def transform_request_to_oci( + endpoint: str, + cohere_body: typing.Dict[str, typing.Any], + compartment_id: str, +) -> typing.Dict[str, typing.Any]: + """ + Transform Cohere V2 request body to OCI format. + + Args: + endpoint: Cohere endpoint name + cohere_body: Original Cohere request body + compartment_id: OCI compartment OCID + + Returns: + Transformed request body in OCI format + """ + model = normalize_model_for_oci(cohere_body.get("model", "")) + + if endpoint == "embed": + # Transform Cohere input_type to OCI format + # Cohere uses: "search_document", "search_query", "classification", "clustering" + # OCI uses: "SEARCH_DOCUMENT", "SEARCH_QUERY", "CLASSIFICATION", "CLUSTERING" + + oci_body = { + "inputs": cohere_body["texts"], + "servingMode": { + "servingType": "ON_DEMAND", + "modelId": model, + }, + "compartmentId": compartment_id, + } + + # Add optional fields only if provided + if "input_type" in cohere_body: + oci_body["inputType"] = cohere_body["input_type"].upper() + + if "truncate" in cohere_body: + oci_body["truncate"] = cohere_body["truncate"].upper() + + if "embedding_types" in cohere_body: + oci_body["embeddingTypes"] = [et.upper() for et in cohere_body["embedding_types"]] + + return oci_body + + elif endpoint in ["chat", "chat_stream"]: + # V2 API uses messages array + chat_request: typing.Dict[str, typing.Any] = { + "apiFormat": "COHEREV2", + } + + # Transform Cohere V2 messages to OCI V2 format + # Cohere sends: [{"role": "user", "content": "text"}] + # OCI expects: [{"role": "USER", "content": [{"type": "TEXT", "text": "..."}]}] + oci_messages = [] + for msg in cohere_body["messages"]: + oci_msg: typing.Dict[str, typing.Any] = { + "role": msg["role"].upper(), + } + + # Transform content + if isinstance(msg.get("content"), str): + # Simple string content -> wrap in array + oci_msg["content"] = [{"type": "TEXT", "text": msg["content"]}] + elif isinstance(msg.get("content"), list): + # Already array format (from tool calls, etc.) + # Transform type field to uppercase for OCI + transformed_content = [] + for item in msg["content"]: + if isinstance(item, dict) and "type" in item: + transformed_item = item.copy() + transformed_item["type"] = item["type"].upper() + transformed_content.append(transformed_item) + else: + transformed_content.append(item) + oci_msg["content"] = transformed_content + else: + oci_msg["content"] = msg.get("content", []) + + # Add tool_calls if present + if "tool_calls" in msg: + oci_msg["toolCalls"] = msg["tool_calls"] + + oci_messages.append(oci_msg) + + chat_request["messages"] = oci_messages + + # V2 optional parameters + if "max_tokens" in cohere_body: + chat_request["maxTokens"] = cohere_body["max_tokens"] + if "temperature" in cohere_body: + chat_request["temperature"] = cohere_body["temperature"] + if "k" in cohere_body: + chat_request["topK"] = cohere_body["k"] + if "p" in cohere_body: + chat_request["topP"] = cohere_body["p"] + if "seed" in cohere_body: + chat_request["seed"] = cohere_body["seed"] + if "frequency_penalty" in cohere_body: + chat_request["frequencyPenalty"] = cohere_body["frequency_penalty"] + if "presence_penalty" in cohere_body: + chat_request["presencePenalty"] = cohere_body["presence_penalty"] + if "stop_sequences" in cohere_body: + chat_request["stopSequences"] = cohere_body["stop_sequences"] + if "tools" in cohere_body: + chat_request["tools"] = cohere_body["tools"] + if "documents" in cohere_body: + chat_request["documents"] = cohere_body["documents"] + if "citation_options" in cohere_body: + chat_request["citationOptions"] = cohere_body["citation_options"] + if "safety_mode" in cohere_body: + chat_request["safetyMode"] = cohere_body["safety_mode"] + # Thinking parameter for Command A Reasoning models + if "thinking" in cohere_body and cohere_body["thinking"] is not None: + thinking = cohere_body["thinking"] + oci_thinking: typing.Dict[str, typing.Any] = {} + if "type" in thinking: + oci_thinking["type"] = thinking["type"].upper() + if "token_budget" in thinking and thinking["token_budget"] is not None: + oci_thinking["tokenBudget"] = thinking["token_budget"] + if oci_thinking: + chat_request["thinking"] = oci_thinking + + # Handle streaming + if "stream" in endpoint or cohere_body.get("stream"): + chat_request["isStream"] = True + + # Top level OCI request structure + oci_body = { + "servingMode": { + "servingType": "ON_DEMAND", + "modelId": model, + }, + "compartmentId": compartment_id, + "chatRequest": chat_request, + } + + return oci_body + + elif endpoint == "rerank": + # OCI rerank uses a flat structure (not nested like chat) + # and "input" instead of "query" + oci_body = { + "servingMode": { + "servingType": "ON_DEMAND", + "modelId": model, + }, + "compartmentId": compartment_id, + "input": cohere_body["query"], # OCI uses "input" not "query" + "documents": cohere_body["documents"], + } + + # Add optional rerank parameters + if "top_n" in cohere_body: + oci_body["topN"] = cohere_body["top_n"] + if "max_chunks_per_doc" in cohere_body: + oci_body["maxChunksPerDocument"] = cohere_body["max_chunks_per_doc"] + + return oci_body + + return cohere_body + + +def transform_oci_response_to_cohere( + endpoint: str, oci_response: typing.Dict[str, typing.Any] +) -> typing.Dict[str, typing.Any]: + """ + Transform OCI response to Cohere V2 format. + + Args: + endpoint: Cohere endpoint name + oci_response: OCI response body + + Returns: + Transformed response in Cohere format + """ + if endpoint == "embed": + # OCI returns embeddings in "embeddings" field, may have multiple types + embeddings_data = oci_response.get("embeddings", {}) + + # V2 expects embeddings as a dict with type keys (float, int8, etc.) + embeddings = embeddings_data if isinstance(embeddings_data, dict) else {"float": embeddings_data} + + # Build proper meta structure + meta = { + "api_version": {"version": "1"}, + } + + # Add usage info if available + if "usage" in oci_response and oci_response["usage"]: + usage = oci_response["usage"] + # OCI usage has inputTokens, outputTokens, totalTokens + input_tokens = usage.get("inputTokens", 0) + output_tokens = usage.get("outputTokens", 0) + + meta["billed_units"] = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + } + meta["tokens"] = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + } + + return { + "id": oci_response.get("id", str(uuid.uuid4())), + "embeddings": embeddings, + "texts": [], # OCI doesn't return texts + "meta": meta, + } + + elif endpoint in ["chat", "chat_stream"]: + chat_response = oci_response.get("chatResponse", {}) + + # Extract usage + usage_data = chat_response.get("usage", {}) + usage = { + "tokens": { + "input_tokens": usage_data.get("inputTokens", 0), + "output_tokens": usage_data.get("completionTokens", 0), + }, + } + if usage_data.get("inputTokens") or usage_data.get("completionTokens"): + usage["billed_units"] = { + "input_tokens": usage_data.get("inputTokens", 0), + "output_tokens": usage_data.get("completionTokens", 0), + } + + # Transform message from OCI format to Cohere format + message = chat_response.get("message", {}) + + # Lowercase the role (OCI returns "ASSISTANT", Cohere expects "assistant") + if "role" in message: + message = {**message, "role": message["role"].lower()} + + # Transform content types from OCI (uppercase) to Cohere (lowercase) + if "content" in message and isinstance(message["content"], list): + transformed_content = [] + for item in message["content"]: + if isinstance(item, dict): + transformed_item = item.copy() + if "type" in transformed_item: + transformed_item["type"] = transformed_item["type"].lower() + transformed_content.append(transformed_item) + else: + transformed_content.append(item) + message = {**message, "content": transformed_content} + + # Convert toolCalls to tool_calls (OCI uses camelCase, Cohere SDK expects snake_case) + if "toolCalls" in message: + tool_calls = message["toolCalls"] + message = {k: v for k, v in message.items() if k != "toolCalls"} + message["tool_calls"] = tool_calls + + return { + "id": chat_response.get("id", str(uuid.uuid4())), + "message": message, + "finish_reason": chat_response.get("finishReason", "COMPLETE"), # V2 keeps uppercase + "usage": usage, + } + + elif endpoint == "rerank": + # OCI returns flat structure with document_ranks + document_ranks = oci_response.get("documentRanks", []) + + return { + "id": oci_response.get("id", str(uuid.uuid4())), + "results": [ + { + "index": r.get("index"), + "relevance_score": r.get("relevanceScore"), + } + for r in document_ranks + ], + "meta": {"api_version": {"version": "1"}}, + } + + return oci_response + + +def transform_oci_stream_wrapper( + stream: SyncByteStream, endpoint: str +) -> typing.Iterator[bytes]: + """ + Wrap OCI stream and transform events to Cohere V2 format. + + Emits the full V2 streaming lifecycle: + message-start -> content-start -> content-delta* -> content-end -> message-end + + Args: + stream: Original OCI stream iterator + endpoint: Cohere endpoint name + + Yields: + Bytes of transformed streaming events + """ + import logging + + generation_id = str(uuid.uuid4()) + emitted_start = False + buffer = b"" + for chunk in stream: + buffer += chunk + while b"\n" in buffer: + line_bytes, buffer = buffer.split(b"\n", 1) + line = line_bytes.decode("utf-8").strip() + + if line.startswith("data: "): + data_str = line[6:] # Remove "data: " prefix + if data_str.strip() == "[DONE]": + # Emit message-end event before stopping + message_end_event = {"type": "message-end"} + yield b"data: " + json.dumps(message_end_event).encode("utf-8") + b"\n\n" + return + + try: + oci_event = json.loads(data_str) + except json.JSONDecodeError: + logging.warning( + "OCI stream: failed to parse SSE event as JSON (endpoint=%s, data=%r)", + endpoint, data_str[:200], + ) + continue + + try: + # Emit message-start and content-start before first content delta + if not emitted_start: + # Detect content type from first event + content_type = "text" + if "message" in oci_event and "content" in oci_event["message"]: + content_list = oci_event["message"]["content"] + if content_list and isinstance(content_list, list) and len(content_list) > 0: + oci_type = content_list[0].get("type", "TEXT").upper() + if oci_type == "THINKING": + content_type = "thinking" + + message_start = { + "type": "message-start", + "id": generation_id, + "delta": {"message": {"role": "assistant"}}, + } + yield b"data: " + json.dumps(message_start).encode("utf-8") + b"\n\n" + + content_start = { + "type": "content-start", + "index": 0, + "delta": {"message": {"content": {"type": content_type}}}, + } + yield b"data: " + json.dumps(content_start).encode("utf-8") + b"\n\n" + emitted_start = True + + cohere_event = transform_stream_event(endpoint, oci_event) + yield b"data: " + json.dumps(cohere_event).encode("utf-8") + b"\n\n" + except Exception as e: + raise RuntimeError( + f"OCI stream event transformation failed for endpoint '{endpoint}': {e}" + ) from e + + +def transform_stream_event( + endpoint: str, oci_event: typing.Dict[str, typing.Any] +) -> typing.Dict[str, typing.Any]: + """ + Transform individual OCI stream event to Cohere V2 format. + + Args: + endpoint: Cohere endpoint name + oci_event: OCI stream event + + Returns: + Transformed event in Cohere format + """ + if endpoint in ["chat_stream", "chat"]: + # V2 API format: OCI returns full message structure in each event + # Extract content from nested structure: message.content[0] + content_type = "text" + content_value = "" + + if "message" in oci_event and "content" in oci_event["message"]: + content_list = oci_event["message"]["content"] + if content_list and isinstance(content_list, list) and len(content_list) > 0: + first_content = content_list[0] + # Detect content type (TEXT or THINKING) + oci_type = first_content.get("type", "TEXT").upper() + if oci_type == "THINKING": + content_type = "thinking" + content_value = first_content.get("thinking", "") + else: + content_type = "text" + content_value = first_content.get("text", "") + + is_finished = "finishReason" in oci_event + + if is_finished: + # Final event - use content-end type + return { + "type": "content-end", + "index": 0, + } + else: + # Content delta event - include type for thinking vs text + delta_content: typing.Dict[str, typing.Any] = {} + if content_type == "thinking": + delta_content["thinking"] = content_value + else: + delta_content["text"] = content_value + + return { + "type": "content-delta", + "index": 0, + "delta": { + "message": { + "content": delta_content, + } + }, + } + + return oci_event diff --git a/tests/test_aws_client_unit.py b/tests/test_aws_client_unit.py index 94e584922..9a66c35e7 100644 --- a/tests/test_aws_client_unit.py +++ b/tests/test_aws_client_unit.py @@ -58,7 +58,7 @@ def capture_aws_request(**kwargs): # type: ignore method="POST", url="https://api.cohere.com/v1/chat", headers={"connection": "keep-alive"}, - json={"model": "cohere.command-r-plus-v1:0", "message": "hello"}, + json={"model": "cohere.I gues-v1:0", "message": "hello"}, ) self.assertEqual(request.url.host, "api.cohere.com") diff --git a/tests/test_oci_client.py b/tests/test_oci_client.py new file mode 100644 index 000000000..f87a59b69 --- /dev/null +++ b/tests/test_oci_client.py @@ -0,0 +1,674 @@ +"""Integration tests for OCI Generative AI client. + +These tests require: +1. OCI SDK installed: pip install oci +2. OCI credentials configured in ~/.oci/config +3. TEST_OCI environment variable set to run +4. OCI_COMPARTMENT_ID environment variable with valid OCI compartment OCID +5. OCI_REGION environment variable (optional, defaults to us-chicago-1) + +Run with: + TEST_OCI=1 OCI_COMPARTMENT_ID=ocid1.compartment.oc1... pytest tests/test_oci_client.py +""" + +import os +import unittest + +import cohere + + +@unittest.skipIf(os.getenv("TEST_OCI") is None, "TEST_OCI not set") +class TestOciClientV2(unittest.TestCase): + """Test OciClientV2 (v2 API) with OCI Generative AI.""" + + def setUp(self): + """Set up OCI v2 client for each test.""" + compartment_id = os.getenv("OCI_COMPARTMENT_ID") + if not compartment_id: + self.skipTest("OCI_COMPARTMENT_ID not set") + + region = os.getenv("OCI_REGION", "us-chicago-1") + profile = os.getenv("OCI_PROFILE", "DEFAULT") + + self.client = cohere.OciClientV2( + oci_region=region, + oci_compartment_id=compartment_id, + oci_profile=profile, + ) + + def test_embed_v2(self): + """Test embedding with v2 client.""" + response = self.client.embed( + model="embed-english-v3.0", + texts=["Hello from v2", "Second text"], + input_type="search_document", + ) + + self.assertIsNotNone(response) + self.assertIsNotNone(response.embeddings) + # V2 returns embeddings as a dict with "float" key + self.assertIsNotNone(response.embeddings.float_) + self.assertEqual(len(response.embeddings.float_), 2) + # Verify embedding dimensions (1024 for embed-english-v3.0) + self.assertEqual(len(response.embeddings.float_[0]), 1024) + + def test_embed_with_model_prefix_v2(self): + """Test embedding with 'cohere.' model prefix on v2 client.""" + response = self.client.embed( + model="cohere.embed-english-v3.0", + texts=["Test with prefix"], + input_type="search_document", + ) + + self.assertIsNotNone(response) + self.assertIsNotNone(response.embeddings) + self.assertIsNotNone(response.embeddings.float_) + self.assertEqual(len(response.embeddings.float_), 1) + + def test_chat_v2(self): + """Test chat with v2 client.""" + response = self.client.chat( + model="command-a-03-2025", + messages=[{"role": "user", "content": "Say hello"}], + ) + + self.assertIsNotNone(response) + self.assertIsNotNone(response.message) + + @unittest.skip( + "Command A Reasoning model (command-a-reasoning-08-2025) may not be available in all regions. " + "Enable this test when the reasoning model is available in your OCI region." + ) + def test_chat_v2_with_thinking(self): + """Test chat with thinking parameter for Command A Reasoning model.""" + from cohere.types import Thinking + + response = self.client.chat( + model="command-a-reasoning-08-2025", + messages=[{"role": "user", "content": "What is 15 * 27? Think step by step."}], + thinking=Thinking(type="enabled", token_budget=5000), + ) + + self.assertIsNotNone(response) + self.assertIsNotNone(response.message) + # The response should contain content (may include thinking content) + self.assertIsNotNone(response.message.content) + + @unittest.skip( + "Command A Reasoning model (command-a-reasoning-08-2025) may not be available in all regions. " + "Enable this test when the reasoning model is available in your OCI region." + ) + def test_chat_stream_v2_with_thinking(self): + """Test streaming chat with thinking parameter for Command A Reasoning model.""" + from cohere.types import Thinking + + events = [] + for event in self.client.chat_stream( + model="command-a-reasoning-08-2025", + messages=[{"role": "user", "content": "What is 15 * 27? Think step by step."}], + thinking=Thinking(type="enabled", token_budget=5000), + ): + events.append(event) + + self.assertTrue(len(events) > 0) + # Verify we received content-delta events + content_delta_events = [e for e in events if hasattr(e, "type") and e.type == "content-delta"] + self.assertTrue(len(content_delta_events) > 0) + + def test_chat_stream_v2(self): + """Test streaming chat with v2 client.""" + events = [] + for event in self.client.chat_stream( + model="command-a-03-2025", + messages=[{"role": "user", "content": "Count from 1 to 3"}], + ): + events.append(event) + + self.assertTrue(len(events) > 0) + # Verify we received content-delta events with text + content_delta_events = [e for e in events if hasattr(e, "type") and e.type == "content-delta"] + self.assertTrue(len(content_delta_events) > 0) + + # Verify we can extract text from events + full_text = "" + for event in events: + if ( + hasattr(event, "delta") + and event.delta + and hasattr(event.delta, "message") + and event.delta.message + and hasattr(event.delta.message, "content") + and event.delta.message.content + and hasattr(event.delta.message.content, "text") + ): + full_text += event.delta.message.content.text + + # Should have received some text + self.assertTrue(len(full_text) > 0) + + @unittest.skip( + "OCI TEXT_RERANK models are base models, not available via on-demand inference. " + "These models require fine-tuning and deployment before use on OCI." + ) + def test_rerank_v2(self): + """Test reranking with v2 client.""" + response = self.client.rerank( + model="rerank-english-v3.1", + query="What is AI?", + documents=["AI is artificial intelligence.", "AI is not natural."], + top_n=1, + ) + + self.assertIsNotNone(response) + self.assertIsNotNone(response.results) + + +@unittest.skipIf(os.getenv("TEST_OCI") is None, "TEST_OCI not set") +class TestOciClientAuthentication(unittest.TestCase): + """Test different OCI authentication methods.""" + + def test_config_file_auth(self): + """Test authentication using OCI config file.""" + compartment_id = os.getenv("OCI_COMPARTMENT_ID") + if not compartment_id: + self.skipTest("OCI_COMPARTMENT_ID not set") + + # Use API_KEY_AUTH profile (DEFAULT may be session-based) + profile = os.getenv("OCI_PROFILE", "API_KEY_AUTH") + client = cohere.OciClientV2( + oci_region="us-chicago-1", + oci_compartment_id=compartment_id, + oci_profile=profile, + ) + + # Test with a simple embed call + response = client.embed( + model="embed-english-v3.0", + texts=["Auth test"], + input_type="search_document", + ) + + self.assertIsNotNone(response) + self.assertIsNotNone(response.embeddings) + self.assertIsNotNone(response.embeddings.float_) + + def test_custom_profile_auth(self): + """Test authentication using custom OCI profile.""" + compartment_id = os.getenv("OCI_COMPARTMENT_ID") + profile = os.getenv("OCI_PROFILE", "DEFAULT") + + if not compartment_id: + self.skipTest("OCI_COMPARTMENT_ID not set") + + client = cohere.OciClientV2( + oci_profile=profile, + oci_region="us-chicago-1", + oci_compartment_id=compartment_id, + ) + + response = client.embed( + model="embed-english-v3.0", + texts=["Profile auth test"], + input_type="search_document", + ) + + self.assertIsNotNone(response) + + +@unittest.skipIf(os.getenv("TEST_OCI") is None, "TEST_OCI not set") +class TestOciClientErrors(unittest.TestCase): + """Test error handling in OCI client.""" + + def test_missing_compartment_id(self): + """Test error when compartment ID is missing.""" + with self.assertRaises(TypeError): + cohere.OciClientV2( + oci_region="us-chicago-1", + # Missing oci_compartment_id + ) + + def test_invalid_model(self): + """Test error handling with invalid model.""" + compartment_id = os.getenv("OCI_COMPARTMENT_ID") + if not compartment_id: + self.skipTest("OCI_COMPARTMENT_ID not set") + + profile = os.getenv("OCI_PROFILE", "API_KEY_AUTH") + client = cohere.OciClientV2( + oci_region="us-chicago-1", + oci_compartment_id=compartment_id, + oci_profile=profile, + ) + + # OCI should return an error for invalid model + with self.assertRaises(Exception): + client.embed( + model="invalid-model-name", + texts=["Test"], + input_type="search_document", + ) + + +@unittest.skipIf(os.getenv("TEST_OCI") is None, "TEST_OCI not set") +class TestOciClientModels(unittest.TestCase): + """Test different Cohere models on OCI.""" + + def setUp(self): + """Set up OCI client for each test.""" + compartment_id = os.getenv("OCI_COMPARTMENT_ID") + if not compartment_id: + self.skipTest("OCI_COMPARTMENT_ID not set") + + region = os.getenv("OCI_REGION", "us-chicago-1") + profile = os.getenv("OCI_PROFILE", "DEFAULT") + + self.client = cohere.OciClientV2( + oci_region=region, + oci_compartment_id=compartment_id, + oci_profile=profile, + ) + + def test_embed_english_v3(self): + """Test embed-english-v3.0 model.""" + response = self.client.embed( + model="embed-english-v3.0", + texts=["Test"], + input_type="search_document", + ) + self.assertIsNotNone(response.embeddings) + self.assertIsNotNone(response.embeddings.float_) + self.assertEqual(len(response.embeddings.float_[0]), 1024) + + def test_embed_light_v3(self): + """Test embed-english-light-v3.0 model.""" + response = self.client.embed( + model="embed-english-light-v3.0", + texts=["Test"], + input_type="search_document", + ) + self.assertIsNotNone(response.embeddings) + self.assertIsNotNone(response.embeddings.float_) + self.assertEqual(len(response.embeddings.float_[0]), 384) + + def test_embed_multilingual_v3(self): + """Test embed-multilingual-v3.0 model.""" + response = self.client.embed( + model="embed-multilingual-v3.0", + texts=["Test"], + input_type="search_document", + ) + self.assertIsNotNone(response.embeddings) + self.assertIsNotNone(response.embeddings.float_) + self.assertEqual(len(response.embeddings.float_[0]), 1024) + + def test_command_a(self): + """Test command-a model for chat.""" + response = self.client.chat( + model="command-a-03-2025", + messages=[{"role": "user", "content": "Hello"}], + ) + self.assertIsNotNone(response.message) + + @unittest.skip( + "OCI TEXT_RERANK models are base models, not available via on-demand inference. " + "These models require fine-tuning and deployment before use on OCI." + ) + def test_rerank_v3(self): + """Test rerank-english-v3.0 model.""" + response = self.client.rerank( + model="rerank-english-v3.1", + query="AI", + documents=["Artificial Intelligence", "Biology"], + ) + self.assertIsNotNone(response.results) + + +class TestOciClientTransformations(unittest.TestCase): + """Unit tests for OCI request/response transformations (no OCI credentials required).""" + + def test_thinking_parameter_transformation(self): + """Test that thinking parameter is correctly transformed to OCI format.""" + from cohere.oci_client import transform_request_to_oci + + cohere_body = { + "model": "command-a-reasoning-08-2025", + "messages": [{"role": "user", "content": "What is 2+2?"}], + "thinking": { + "type": "enabled", + "token_budget": 10000, + }, + } + + result = transform_request_to_oci("chat", cohere_body, "compartment-123") + + # Verify thinking parameter is transformed with camelCase for OCI API + chat_request = result["chatRequest"] + self.assertIn("thinking", chat_request) + self.assertEqual(chat_request["thinking"]["type"], "ENABLED") + self.assertEqual(chat_request["thinking"]["tokenBudget"], 10000) # camelCase for OCI + + def test_thinking_parameter_disabled(self): + """Test that disabled thinking is correctly transformed.""" + from cohere.oci_client import transform_request_to_oci + + cohere_body = { + "model": "command-a-reasoning-08-2025", + "messages": [{"role": "user", "content": "Hello"}], + "thinking": { + "type": "disabled", + }, + } + + result = transform_request_to_oci("chat", cohere_body, "compartment-123") + + chat_request = result["chatRequest"] + self.assertIn("thinking", chat_request) + self.assertEqual(chat_request["thinking"]["type"], "DISABLED") + self.assertNotIn("token_budget", chat_request["thinking"]) + + def test_thinking_response_transformation(self): + """Test that thinking content in response is correctly transformed.""" + from cohere.oci_client import transform_oci_response_to_cohere + + oci_response = { + "chatResponse": { + "id": "test-id", + "message": { + "role": "ASSISTANT", + "content": [ + {"type": "THINKING", "thinking": "Let me think about this..."}, + {"type": "TEXT", "text": "The answer is 4."}, + ], + }, + "finishReason": "COMPLETE", + "usage": {"inputTokens": 10, "completionTokens": 20}, + } + } + + result = transform_oci_response_to_cohere("chat", oci_response) + + # Verify content types are lowercased + self.assertEqual(result["message"]["content"][0]["type"], "thinking") + self.assertEqual(result["message"]["content"][1]["type"], "text") + + def test_stream_event_thinking_transformation(self): + """Test that thinking content in stream events is correctly transformed.""" + from cohere.oci_client import transform_stream_event + + # OCI thinking event + oci_event = { + "message": { + "content": [{"type": "THINKING", "thinking": "Reasoning step..."}] + } + } + + result = transform_stream_event("chat", oci_event) + + self.assertEqual(result["type"], "content-delta") + self.assertIn("thinking", result["delta"]["message"]["content"]) + self.assertEqual(result["delta"]["message"]["content"]["thinking"], "Reasoning step...") + + def test_stream_event_text_transformation(self): + """Test that text content in stream events is correctly transformed.""" + from cohere.oci_client import transform_stream_event + + # OCI text event + oci_event = { + "message": { + "content": [{"type": "TEXT", "text": "The answer is..."}] + } + } + + result = transform_stream_event("chat", oci_event) + + self.assertEqual(result["type"], "content-delta") + self.assertIn("text", result["delta"]["message"]["content"]) + self.assertEqual(result["delta"]["message"]["content"]["text"], "The answer is...") + + def test_thinking_parameter_none(self): + """Test that thinking=None does not crash (issue: null guard).""" + from cohere.oci_client import transform_request_to_oci + + cohere_body = { + "model": "command-a-03-2025", + "messages": [{"role": "user", "content": "Hello"}], + "thinking": None, # Explicitly set to None + } + + # Should not crash with TypeError + result = transform_request_to_oci("chat", cohere_body, "compartment-123") + + chat_request = result["chatRequest"] + # thinking should not be in request when None + self.assertNotIn("thinking", chat_request) + + def test_v2_response_role_lowercased(self): + """Test that V2 response message role is lowercased.""" + from cohere.oci_client import transform_oci_response_to_cohere + + oci_response = { + "chatResponse": { + "id": "test-id", + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "Hello"}], + }, + "finishReason": "COMPLETE", + "usage": {"inputTokens": 10, "completionTokens": 20}, + } + } + + result = transform_oci_response_to_cohere("chat", oci_response) + + # Role should be lowercased + self.assertEqual(result["message"]["role"], "assistant") + + def test_v2_response_finish_reason_uppercase(self): + """Test that V2 response finish_reason stays uppercase.""" + from cohere.oci_client import transform_oci_response_to_cohere + + oci_response = { + "chatResponse": { + "id": "test-id", + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "Hello"}], + }, + "finishReason": "MAX_TOKENS", + "usage": {"inputTokens": 10, "completionTokens": 20}, + } + } + + result = transform_oci_response_to_cohere("chat", oci_response) + + # V2 finish_reason should stay uppercase + self.assertEqual(result["finish_reason"], "MAX_TOKENS") + + def test_v2_response_tool_calls_conversion(self): + """Test that V2 response converts toolCalls to tool_calls.""" + from cohere.oci_client import transform_oci_response_to_cohere + + oci_response = { + "chatResponse": { + "id": "test-id", + "message": { + "role": "ASSISTANT", + "content": [{"type": "TEXT", "text": "I'll help with that."}], + "toolCalls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "London"}'}, + } + ], + }, + "finishReason": "TOOL_CALL", + "usage": {"inputTokens": 10, "completionTokens": 20}, + } + } + + result = transform_oci_response_to_cohere("chat", oci_response) + + # toolCalls should be converted to tool_calls + self.assertIn("tool_calls", result["message"]) + self.assertNotIn("toolCalls", result["message"]) + self.assertEqual(len(result["message"]["tool_calls"]), 1) + self.assertEqual(result["message"]["tool_calls"][0]["id"], "call_123") + + + def test_normalize_model_for_oci(self): + """Test model name normalization for OCI.""" + from cohere.oci_client import normalize_model_for_oci + + # Plain model name gets cohere. prefix + self.assertEqual(normalize_model_for_oci("command-a-03-2025"), "cohere.command-a-03-2025") + # Already prefixed passes through + self.assertEqual(normalize_model_for_oci("cohere.embed-english-v3.0"), "cohere.embed-english-v3.0") + # OCID passes through + self.assertEqual( + normalize_model_for_oci("ocid1.generativeaimodel.oc1.us-chicago-1.abc"), + "ocid1.generativeaimodel.oc1.us-chicago-1.abc", + ) + + def test_transform_embed_request(self): + """Test embed request transformation to OCI format.""" + from cohere.oci_client import transform_request_to_oci + + body = { + "model": "embed-english-v3.0", + "texts": ["hello", "world"], + "input_type": "search_document", + "truncate": "end", + "embedding_types": ["float", "int8"], + } + result = transform_request_to_oci("embed", body, "compartment-123") + + self.assertEqual(result["inputs"], ["hello", "world"]) + self.assertEqual(result["inputType"], "SEARCH_DOCUMENT") + self.assertEqual(result["truncate"], "END") + self.assertEqual(result["embeddingTypes"], ["FLOAT", "INT8"]) + self.assertEqual(result["compartmentId"], "compartment-123") + self.assertEqual(result["servingMode"]["modelId"], "cohere.embed-english-v3.0") + + def test_transform_chat_request_optional_params(self): + """Test chat request transformation includes optional params.""" + from cohere.oci_client import transform_request_to_oci + + body = { + "model": "command-a-03-2025", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 100, + "temperature": 0.7, + "stop_sequences": ["END"], + "frequency_penalty": 0.5, + } + result = transform_request_to_oci("chat", body, "compartment-123") + + chat_req = result["chatRequest"] + self.assertEqual(chat_req["maxTokens"], 100) + self.assertEqual(chat_req["temperature"], 0.7) + self.assertEqual(chat_req["stopSequences"], ["END"]) + self.assertEqual(chat_req["frequencyPenalty"], 0.5) + + def test_get_oci_url_known_endpoints(self): + """Test URL generation for known endpoints.""" + from cohere.oci_client import get_oci_url + + url = get_oci_url("us-chicago-1", "embed") + self.assertIn("/actions/embedText", url) + + url = get_oci_url("us-chicago-1", "chat") + self.assertIn("/actions/chat", url) + + url = get_oci_url("us-chicago-1", "chat_stream", stream=True) + self.assertIn("/actions/chat", url) + + def test_get_oci_url_unknown_endpoint_raises(self): + """Test that unknown endpoints raise ValueError instead of producing bad URLs.""" + from cohere.oci_client import get_oci_url + + with self.assertRaises(ValueError) as ctx: + get_oci_url("us-chicago-1", "unknown_endpoint") + self.assertIn("not supported", str(ctx.exception)) + + def test_load_oci_config_missing_private_key_raises(self): + """Test that direct credentials without private key raises clear error.""" + from unittest.mock import patch, MagicMock + from cohere.oci_client import _load_oci_config + + with patch("cohere.oci_client.lazy_oci", return_value=MagicMock()): + with self.assertRaises(ValueError) as ctx: + _load_oci_config( + auth_type="api_key", + config_path=None, + profile=None, + user_id="ocid1.user.oc1...", + fingerprint="xx:xx:xx", + tenancy_id="ocid1.tenancy.oc1...", + # No private_key_path or private_key_content + ) + self.assertIn("oci_private_key_path", str(ctx.exception)) + + def test_stream_wrapper_emits_full_event_lifecycle(self): + """Test that stream emits message-start, content-start, content-delta, content-end, message-end.""" + import json + from cohere.oci_client import transform_oci_stream_wrapper + + chunks = [ + b'data: {"message": {"content": [{"type": "TEXT", "text": "Hello"}]}}\n', + b'data: {"message": {"content": [{"type": "TEXT", "text": " world"}]}, "finishReason": "COMPLETE"}\n', + b'data: [DONE]\n', + ] + + events = [] + for raw in transform_oci_stream_wrapper(iter(chunks), "chat"): + line = raw.decode("utf-8").strip() + if line.startswith("data: "): + events.append(json.loads(line[6:])) + + event_types = [e["type"] for e in events] + self.assertEqual(event_types[0], "message-start") + self.assertEqual(event_types[1], "content-start") + self.assertEqual(event_types[2], "content-delta") + self.assertEqual(event_types[3], "content-end") + self.assertEqual(event_types[4], "message-end") + + # Verify message-start has id and role + self.assertIn("id", events[0]) + self.assertEqual(events[0]["delta"]["message"]["role"], "assistant") + + # Verify content-start has index and type + self.assertEqual(events[1]["index"], 0) + self.assertEqual(events[1]["delta"]["message"]["content"]["type"], "text") + + def test_stream_wrapper_skips_malformed_json_with_warning(self): + """Test that malformed JSON in SSE stream is skipped (not silently swallowed).""" + import json + from cohere.oci_client import transform_oci_stream_wrapper + + chunks = [ + b'data: not-valid-json\n', + b'data: {"message": {"content": [{"type": "TEXT", "text": "hello"}]}}\n', + b'data: [DONE]\n', + ] + events = list(transform_oci_stream_wrapper(iter(chunks), "chat")) + # Should get message-start + content-start + content-delta + message-end (malformed line skipped) + self.assertEqual(len(events), 4) + + def test_stream_wrapper_raises_on_transform_error(self): + """Test that transform errors in stream produce OCI-specific error, not opaque httpx error.""" + import json + from cohere.oci_client import transform_oci_stream_wrapper + + # Event with structure that will cause transform_stream_event to fail + # (message is None, causing TypeError on "content" in None) + chunks = [ + b'data: {"message": null}\n', + ] + with self.assertRaises(RuntimeError) as ctx: + list(transform_oci_stream_wrapper(iter(chunks), "chat")) + self.assertIn("OCI stream event transformation failed", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main()