Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions litellm/llms/oci/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
62 changes: 56 additions & 6 deletions litellm/proxy/public_endpoints/provider_create_fields.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion litellm/types/proxy/public_endpoints/public_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
199 changes: 199 additions & 0 deletions tests/litellm/llms/oci/chat/test_oci_chat_transformation.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -247,6 +249,13 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
>
<Button2 icon={<UploadOutlined />}>Click to Upload</Button2>
</Upload>
) : field.type === "textarea" ? (
<Input.TextArea
placeholder={field.placeholder}
defaultValue={field.defaultValue}
rows={6}
style={{ fontFamily: "monospace", fontSize: "12px" }}
/>
) : (
<TextInput
placeholder={field.placeholder}
Expand Down
2 changes: 1 addition & 1 deletion ui/litellm-dashboard/src/components/networking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export interface ProviderCredentialFieldMetadata {
placeholder?: string | null;
tooltip?: string | null;
required?: boolean;
field_type?: "text" | "password" | "select" | "upload";
field_type?: "text" | "password" | "select" | "upload" | "textarea";
options?: string[] | null;
default_value?: string | null;
}
Expand Down
Loading