mirror of
https://github.com/BerriAI/litellm.git
synced 2025-12-06 11:33:26 +08:00
feat(oci): Add textarea field type for OCI private key input (#17159)
This enables Oracle Cloud Infrastructure (OCI) GenAI authentication via the UI by allowing users to paste their PEM private key content directly into a multiline textarea field. Changes: - Add `textarea` field type to UI component system - Configure OCI provider with proper credential fields (oci_key, oci_user, oci_fingerprint, oci_tenancy, oci_region, oci_compartment_id) - Handle PEM content newline normalization (\\n -> \n, \r\n -> \n) - Use OCIError for consistent error handling Previously OCI only supported file-based authentication (oci_key_file), which doesn't work for UI-based model configuration. This adds support for inline PEM content via the new oci_key field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
e5f7a0b0a5
commit
2905feb889
@@ -416,15 +416,34 @@ class OCIChatConfig(BaseConfig):
|
||||
"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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
199
tests/litellm/llms/oci/chat/test_oci_chat_transformation.py
Normal file
199
tests/litellm/llms/oci/chat/test_oci_chat_transformation.py
Normal file
@@ -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
|
||||
@@ -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<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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user