diff --git a/litellm/llms/oci/chat/transformation.py b/litellm/llms/oci/chat/transformation.py index 5107f76fc844..038895a39e5f 100644 --- a/litellm/llms/oci/chat/transformation.py +++ b/litellm/llms/oci/chat/transformation.py @@ -416,15 +416,34 @@ def _sign_with_manual_credentials( "Please install it with: pip install cryptography" ) from e + # Handle oci_key - it should be a string (PEM content) + oci_key_content = None + if oci_key: + if isinstance(oci_key, str): + oci_key_content = oci_key + # Fix common issues with PEM content + # Replace escaped newlines with actual newlines + oci_key_content = oci_key_content.replace("\\n", "\n") + # Ensure proper line endings + if "\r\n" in oci_key_content: + oci_key_content = oci_key_content.replace("\r\n", "\n") + else: + raise OCIError( + status_code=400, + message=f"oci_key must be a string containing the PEM private key content. " + f"Got type: {type(oci_key).__name__}", + ) + private_key = ( - load_private_key_from_str(oci_key) - if oci_key + load_private_key_from_str(oci_key_content) + if oci_key_content else load_private_key_from_file(oci_key_file) if oci_key_file else None ) if private_key is None: - raise Exception( - "Private key is required for OCI authentication. Please provide either oci_key or oci_key_file." + raise OCIError( + status_code=400, + message="Private key is required for OCI authentication. Please provide either oci_key or oci_key_file.", ) signature = private_key.sign( diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 8e08bd8d991a..89d52544c310 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2124,17 +2124,67 @@ "litellm_provider": "oci", "credential_fields": [ { - "key": "api_key", - "label": "API Key", - "placeholder": null, - "tooltip": null, + "key": "oci_user", + "label": "OCI User OCID", + "placeholder": "ocid1.user.oc1..aaaaaaaaexample", + "tooltip": "The OCID of the user making the API call", "required": true, - "field_type": "password", + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "oci_fingerprint", + "label": "OCI Key Fingerprint", + "placeholder": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99", + "tooltip": "The fingerprint of the API signing key", + "required": true, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "oci_tenancy", + "label": "OCI Tenancy OCID", + "placeholder": "ocid1.tenancy.oc1..aaaaaaaaexample", + "tooltip": "The OCID of your tenancy", + "required": true, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "oci_region", + "label": "OCI Region", + "placeholder": "us-ashburn-1", + "tooltip": "The OCI region identifier (e.g., us-ashburn-1, eu-frankfurt-1)", + "required": true, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "oci_compartment_id", + "label": "OCI Compartment OCID", + "placeholder": "ocid1.compartment.oc1..aaaaaaaaexample", + "tooltip": "The OCID of the compartment containing the GenAI resources", + "required": true, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "oci_key", + "label": "OCI Private Key (PEM)", + "placeholder": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", + "tooltip": "The full PEM-encoded private key content for API signing. Paste the entire key including BEGIN/END markers.", + "required": true, + "field_type": "textarea", "options": null, "default_value": null } ], - "default_model_placeholder": "oci/xai.grok-4" + "default_model_placeholder": "oci/xai.grok-3" }, { "provider": "OVHCLOUD", diff --git a/litellm/types/proxy/public_endpoints/public_endpoints.py b/litellm/types/proxy/public_endpoints/public_endpoints.py index 7edf05dc9474..aca58e369216 100644 --- a/litellm/types/proxy/public_endpoints/public_endpoints.py +++ b/litellm/types/proxy/public_endpoints/public_endpoints.py @@ -16,7 +16,7 @@ class ProviderCredentialField(BaseModel): placeholder: Optional[str] = None tooltip: Optional[str] = None required: bool = False - field_type: Literal["text", "password", "select", "upload"] = "text" + field_type: Literal["text", "password", "select", "upload", "textarea"] = "text" options: Optional[List[str]] = None default_value: Optional[str] = None diff --git a/tests/litellm/llms/oci/chat/test_oci_chat_transformation.py b/tests/litellm/llms/oci/chat/test_oci_chat_transformation.py new file mode 100644 index 000000000000..c23f4501a2c1 --- /dev/null +++ b/tests/litellm/llms/oci/chat/test_oci_chat_transformation.py @@ -0,0 +1,199 @@ +""" +Tests for OCI Chat Transformation module. + +These tests verify the OCI credential handling, particularly the PEM key +normalization logic for handling different newline formats. +""" + +import os +import sys +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +from litellm.llms.oci.chat.transformation import OCIChatConfig +from litellm.llms.oci.common_utils import OCIError + + +@pytest.fixture +def config(): + return OCIChatConfig() + + +class TestOCIKeyNormalization: + """Tests for OCI private key content normalization.""" + + def test_oci_key_with_escaped_newlines(self, config): + """Test that escaped newlines (\\n) are converted to actual newlines.""" + # Simulate PEM content with escaped newlines (as would come from JSON/UI input) + escaped_pem = "-----BEGIN RSA PRIVATE KEY-----\\nMIIEowIBAAKCAQEA...\\n-----END RSA PRIVATE KEY-----" + + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": escaped_pem, + } + + # We can't fully test signing without a real key, but we can verify + # the error message indicates the key was processed (not a type error) + with pytest.raises(Exception) as exc_info: + config._sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + # The error should be about key format/loading, not about type + # This confirms the string was processed and newlines were normalized + error_message = str(exc_info.value) + assert "must be a string" not in error_message.lower() + + def test_oci_key_with_crlf_newlines(self, config): + """Test that Windows-style CRLF newlines are normalized to LF.""" + # Simulate PEM content with CRLF newlines + crlf_pem = "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA...\r\n-----END RSA PRIVATE KEY-----" + + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": crlf_pem, + } + + with pytest.raises(Exception) as exc_info: + config._sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + error_message = str(exc_info.value) + assert "must be a string" not in error_message.lower() + + def test_oci_key_rejects_non_string_type(self, config): + """Test that non-string oci_key values raise OCIError.""" + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": {"invalid": "dict"}, # Wrong type + } + + with pytest.raises(OCIError) as exc_info: + config._sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + assert exc_info.value.status_code == 400 + assert "must be a string" in str(exc_info.value.message) + assert "dict" in str(exc_info.value.message) + + def test_oci_key_rejects_list_type(self, config): + """Test that list oci_key values raise OCIError.""" + optional_params = { + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_key": ["invalid", "list"], # Wrong type + } + + with pytest.raises(OCIError) as exc_info: + config._sign_with_manual_credentials( + headers={}, + optional_params=optional_params, + request_data={"test": "data"}, + api_base="https://test.oci.oraclecloud.com/api", + ) + + assert exc_info.value.status_code == 400 + assert "must be a string" in str(exc_info.value.message) + assert "list" in str(exc_info.value.message) + + +class TestOCIValidateEnvironment: + """Tests for OCI environment validation.""" + + def test_missing_required_credentials_raises_error(self, config): + """Test that missing required credentials raise an error.""" + with pytest.raises(Exception) as exc_info: + config.validate_environment( + headers={}, + model="oci/xai.grok-3", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, # No credentials provided + litellm_params={}, + api_key=None, + api_base=None, + ) + + error_message = str(exc_info.value) + assert "oci_user" in error_message + assert "oci_fingerprint" in error_message + assert "oci_tenancy" in error_message + + def test_validate_environment_with_all_credentials(self, config): + """Test that validation passes with all required credentials.""" + headers = config.validate_environment( + headers={}, + model="oci/xai.grok-3", + messages=[{"role": "user", "content": "Hello"}], + optional_params={ + "oci_user": "ocid1.user.oc1..test", + "oci_fingerprint": "aa:bb:cc:dd", + "oci_tenancy": "ocid1.tenancy.oc1..test", + "oci_region": "us-ashburn-1", + "oci_compartment_id": "ocid1.compartment.oc1..test", + "oci_key": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + litellm_params={}, + api_key=None, + api_base=None, + ) + + assert headers["content-type"] == "application/json" + assert "user-agent" in headers + + +class TestOCIGetCompleteUrl: + """Tests for OCI URL generation.""" + + def test_get_complete_url_default_region(self, config): + """Test URL generation with default region.""" + url = config.get_complete_url( + api_base=None, + api_key=None, + model="oci/xai.grok-3", + optional_params={}, + litellm_params={}, + stream=False, + ) + + assert "us-ashburn-1" in url + assert "inference.generativeai" in url + assert "/20231130/actions/chat" in url + + def test_get_complete_url_custom_region(self, config): + """Test URL generation with custom region.""" + url = config.get_complete_url( + api_base=None, + api_key=None, + model="oci/xai.grok-3", + optional_params={"oci_region": "eu-frankfurt-1"}, + litellm_params={}, + stream=False, + ) + + assert "eu-frankfurt-1" in url + assert "inference.generativeai" in url diff --git a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx index 892e9e0197e3..24df0ac21ef0 100644 --- a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx +++ b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx @@ -1,7 +1,7 @@ import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; import { UploadOutlined } from "@ant-design/icons"; import { Text, TextInput } from "@tremor/react"; -import { Button as Button2, Col, Form, Row, Select, Typography, Upload, UploadProps } from "antd"; +import { Button as Button2, Col, Form, Input, Row, Select, Typography, Upload, UploadProps } from "antd"; import React from "react"; import { CredentialItem, ProviderCredentialFieldMetadata } from "../networking"; import { provider_map, Providers } from "../provider_info_helpers"; @@ -18,7 +18,7 @@ interface ProviderCredentialField { placeholder?: string; tooltip?: string; required?: boolean; - type?: "text" | "password" | "select" | "upload"; + type?: "text" | "password" | "select" | "upload" | "textarea"; options?: string[]; defaultValue?: string; } @@ -36,7 +36,9 @@ const mapFieldMetadataToUiField = (field: ProviderCredentialFieldMetadata): Prov ? "select" : field.field_type === "upload" ? "upload" - : "text"; + : field.field_type === "textarea" + ? "textarea" + : "text"; return { key: field.key, @@ -247,6 +249,13 @@ const ProviderSpecificFields: React.FC = ({ selecte > }>Click to Upload + ) : field.type === "textarea" ? ( + ) : (