fa45d8aa5f
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
Privoxy对node122:18003返回500,直连正常
570 lines
20 KiB
Python
570 lines
20 KiB
Python
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from email.utils import formatdate
|
|
from typing import Any, Dict, Optional, Protocol, Tuple
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
|
|
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
|
|
|
try:
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
|
|
_CRYPTOGRAPHY_AVAILABLE = True
|
|
except ImportError:
|
|
_CRYPTOGRAPHY_AVAILABLE = False
|
|
|
|
try:
|
|
from litellm._version import version as _litellm_version
|
|
except ImportError:
|
|
_litellm_version = "0.0.0"
|
|
|
|
|
|
# OCI GenAI REST API version — stable since service launch, unlikely to change
|
|
OCI_API_VERSION = "20231130"
|
|
|
|
|
|
def _require_cryptography() -> None:
|
|
if not _CRYPTOGRAPHY_AVAILABLE:
|
|
raise ImportError(
|
|
"cryptography package is required for OCI authentication. "
|
|
"Please install it with: pip install cryptography"
|
|
)
|
|
|
|
|
|
class OCIError(BaseLLMException):
|
|
def __init__(
|
|
self,
|
|
status_code: int,
|
|
message: str,
|
|
headers: Optional[httpx.Headers] = None,
|
|
):
|
|
super().__init__(
|
|
status_code=status_code,
|
|
message=message,
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OCI signing protocol and helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class OCISignerProtocol(Protocol):
|
|
"""
|
|
Protocol for OCI request signers (e.g., oci.signer.Signer).
|
|
|
|
Compatible with the OCI Python SDK's Signer class.
|
|
See: https://docs.oracle.com/en-us/iaas/tools/python/latest/api/signing.html
|
|
"""
|
|
|
|
def do_request_sign(
|
|
self, request: Any, *, enforce_content_headers: bool = False
|
|
) -> None:
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class OCIRequestWrapper:
|
|
"""
|
|
Wrapper for HTTP requests compatible with OCI signer interface.
|
|
|
|
Wraps request data in the format expected by OCI SDK signers, which require
|
|
objects with method, url, headers, body, and path_url attributes.
|
|
"""
|
|
|
|
method: str
|
|
url: str
|
|
headers: dict
|
|
body: bytes
|
|
|
|
@property
|
|
def path_url(self) -> str:
|
|
"""Returns the path + query string for OCI signing."""
|
|
parsed = urlparse(self.url)
|
|
return parsed.path + ("?" + parsed.query if parsed.query else "")
|
|
|
|
|
|
def sha256_base64(data: bytes) -> str:
|
|
# SHA-256 is used here to compute the x-content-sha256 header required by the
|
|
# OCI HTTP signing specification (RSA-SHA256 request signing), not for password
|
|
# or secret hashing. This is the correct and mandated algorithm for this purpose.
|
|
# See: https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm
|
|
#
|
|
# ``usedforsecurity=False`` declares non-security intent to static analyzers
|
|
# (CodeQL ``py/weak-sensitive-data-hashing``) — without it the request body
|
|
# gets flagged as "password-like data" via taint tracking.
|
|
digest = hashlib.sha256(data, usedforsecurity=False).digest() # noqa: S324
|
|
return base64.b64encode(digest).decode()
|
|
|
|
|
|
def build_signature_string(
|
|
method: str, path: str, headers: dict, signed_headers: list
|
|
) -> str:
|
|
lines = []
|
|
for header in signed_headers:
|
|
if header == "(request-target)":
|
|
value = f"{method.lower()} {path}"
|
|
else:
|
|
value = headers[header]
|
|
lines.append(f"{header}: {value}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def load_private_key_from_str(key_str: str) -> Any:
|
|
_require_cryptography()
|
|
key = serialization.load_pem_private_key( # type: ignore[union-attr]
|
|
key_str.encode("utf-8"),
|
|
password=None,
|
|
)
|
|
if not isinstance(key, rsa.RSAPrivateKey): # type: ignore[union-attr]
|
|
raise TypeError(
|
|
"The provided private key is not an RSA key, which is required for OCI signing."
|
|
)
|
|
return key
|
|
|
|
|
|
def load_private_key_from_file(file_path: str) -> Any:
|
|
"""Loads a private key from a file path."""
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
key_str = f.read().strip()
|
|
except FileNotFoundError:
|
|
raise FileNotFoundError(f"Private key file not found: {file_path}")
|
|
except OSError as e:
|
|
raise OSError(f"Failed to read private key file '{file_path}': {e}") from e
|
|
|
|
if not key_str:
|
|
raise ValueError(f"Private key file is empty: {file_path}")
|
|
|
|
return load_private_key_from_str(key_str)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Env-var credential resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_OCI_REGION_ENV = "OCI_REGION"
|
|
_OCI_USER_ENV = "OCI_USER"
|
|
_OCI_FINGERPRINT_ENV = "OCI_FINGERPRINT"
|
|
_OCI_TENANCY_ENV = "OCI_TENANCY"
|
|
_OCI_KEY_FILE_ENV = "OCI_KEY_FILE"
|
|
_OCI_KEY_ENV = "OCI_KEY"
|
|
_OCI_COMPARTMENT_ID_ENV = "OCI_COMPARTMENT_ID"
|
|
|
|
|
|
def resolve_oci_credentials(optional_params: dict) -> dict:
|
|
"""
|
|
Merge OCI credentials from optional_params (explicit, always wins) and
|
|
environment variables (fallback).
|
|
|
|
Returns a dict with resolved values for:
|
|
oci_region, oci_user, oci_fingerprint, oci_tenancy,
|
|
oci_key, oci_key_file, oci_compartment_id
|
|
"""
|
|
return {
|
|
"oci_region": optional_params.get("oci_region")
|
|
or os.environ.get(_OCI_REGION_ENV)
|
|
or "us-ashburn-1",
|
|
"oci_user": optional_params.get("oci_user") or os.environ.get(_OCI_USER_ENV),
|
|
"oci_fingerprint": optional_params.get("oci_fingerprint")
|
|
or os.environ.get(_OCI_FINGERPRINT_ENV),
|
|
"oci_tenancy": optional_params.get("oci_tenancy")
|
|
or os.environ.get(_OCI_TENANCY_ENV),
|
|
"oci_key": optional_params.get("oci_key") or os.environ.get(_OCI_KEY_ENV),
|
|
"oci_key_file": optional_params.get("oci_key_file")
|
|
or os.environ.get(_OCI_KEY_FILE_ENV),
|
|
"oci_compartment_id": optional_params.get("oci_compartment_id")
|
|
or os.environ.get(_OCI_COMPARTMENT_ID_ENV),
|
|
}
|
|
|
|
|
|
_OCI_REGION_RE = re.compile(r"^[a-z][a-z0-9-]{0,30}[a-z0-9]$")
|
|
_OCI_ACTION_PATH_RE = re.compile(rf"/{OCI_API_VERSION}/actions/[^/?#]+/?$")
|
|
|
|
|
|
def get_oci_base_url(optional_params: dict, api_base: Optional[str] = None) -> str:
|
|
"""Return the OCI inference base URL, respecting any explicit api_base override.
|
|
|
|
If ``api_base`` already ends with a fully-formed OCI action path
|
|
(``/{OCI_API_VERSION}/actions/<name>``), that suffix is stripped so callers
|
|
can append their own action path without producing a doubled URL.
|
|
"""
|
|
if api_base:
|
|
return _OCI_ACTION_PATH_RE.sub("", api_base).rstrip("/")
|
|
creds = resolve_oci_credentials(optional_params)
|
|
region = creds["oci_region"]
|
|
if not isinstance(region, str) or not _OCI_REGION_RE.match(region):
|
|
raise OCIError(
|
|
status_code=400,
|
|
message=(
|
|
f"Invalid OCI region {region!r}: must match "
|
|
"^[a-z][a-z0-9-]{0,30}[a-z0-9]$ (e.g. 'us-ashburn-1')."
|
|
),
|
|
)
|
|
return f"https://inference.generativeai.{region}.oci.oraclecloud.com"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signing implementations (shared by chat, embed, and rerank configs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def sign_with_oci_signer(
|
|
headers: dict,
|
|
optional_params: dict,
|
|
request_data: dict,
|
|
api_base: str,
|
|
) -> Tuple[dict, bytes]:
|
|
"""Sign a request using an OCI SDK Signer object passed in optional_params."""
|
|
oci_signer = optional_params.get("oci_signer")
|
|
body = json.dumps(request_data).encode("utf-8")
|
|
method = str(optional_params.get("method", "POST")).upper()
|
|
|
|
if method not in {"POST", "GET", "PUT", "DELETE", "PATCH"}:
|
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
|
|
prepared_headers = {**headers}
|
|
prepared_headers.setdefault("content-type", "application/json")
|
|
prepared_headers.setdefault("content-length", str(len(body)))
|
|
|
|
request_wrapper = OCIRequestWrapper(
|
|
method=method, url=api_base, headers=prepared_headers, body=body
|
|
)
|
|
|
|
if oci_signer is None:
|
|
raise ValueError("oci_signer cannot be None when calling sign_with_oci_signer")
|
|
|
|
try:
|
|
oci_signer.do_request_sign(request_wrapper, enforce_content_headers=True)
|
|
except Exception as e:
|
|
raise OCIError(
|
|
status_code=500,
|
|
message=(
|
|
f"Failed to sign request with provided oci_signer: {str(e)}. "
|
|
"The signer must implement the OCI SDK Signer interface with a "
|
|
"do_request_sign(request, enforce_content_headers=True) method. "
|
|
"See: https://docs.oracle.com/en-us/iaas/tools/python/latest/api/signing.html"
|
|
),
|
|
) from e
|
|
|
|
headers.update(request_wrapper.headers)
|
|
return headers, body
|
|
|
|
|
|
def sign_with_manual_credentials(
|
|
headers: dict,
|
|
optional_params: dict,
|
|
request_data: dict,
|
|
api_base: str,
|
|
) -> Tuple[dict, bytes]:
|
|
"""Sign a request using manually provided OCI credentials (user/fingerprint/tenancy/key)."""
|
|
creds = resolve_oci_credentials(optional_params)
|
|
oci_user = creds["oci_user"]
|
|
oci_fingerprint = creds["oci_fingerprint"]
|
|
oci_tenancy = creds["oci_tenancy"]
|
|
oci_key = creds["oci_key"]
|
|
oci_key_file = creds["oci_key_file"]
|
|
|
|
if (
|
|
not oci_user
|
|
or not oci_fingerprint
|
|
or not oci_tenancy
|
|
or not (oci_key or oci_key_file)
|
|
):
|
|
raise OCIError(
|
|
status_code=401,
|
|
message=(
|
|
"Missing required OCI credentials: oci_user, oci_fingerprint, oci_tenancy, "
|
|
"and at least one of oci_key or oci_key_file. "
|
|
"These can also be supplied via environment variables: "
|
|
f"{_OCI_USER_ENV}, {_OCI_FINGERPRINT_ENV}, {_OCI_TENANCY_ENV}, {_OCI_KEY_ENV} (or {_OCI_KEY_FILE_ENV}). "
|
|
"Alternatively, provide an oci_signer object from the OCI SDK."
|
|
),
|
|
)
|
|
|
|
method = str(optional_params.get("method", "POST")).upper()
|
|
body = json.dumps(request_data).encode("utf-8")
|
|
parsed = urlparse(api_base)
|
|
path = parsed.path or "/"
|
|
host = parsed.netloc
|
|
|
|
date = formatdate(usegmt=True)
|
|
content_type = headers.get("content-type", "application/json")
|
|
content_length = str(len(body))
|
|
x_content_sha256 = sha256_base64(body)
|
|
|
|
headers_to_sign: Dict[str, str] = {
|
|
"date": date,
|
|
"host": host,
|
|
"content-type": content_type,
|
|
"content-length": content_length,
|
|
"x-content-sha256": x_content_sha256,
|
|
}
|
|
|
|
signed_header_names = [
|
|
"date",
|
|
"(request-target)",
|
|
"host",
|
|
"content-length",
|
|
"content-type",
|
|
"x-content-sha256",
|
|
]
|
|
signing_string = build_signature_string(
|
|
method, path, headers_to_sign, signed_header_names
|
|
)
|
|
|
|
_require_cryptography()
|
|
|
|
# Resolve the private key — prefer inline PEM content over file path
|
|
oci_key_content: Optional[str] = None
|
|
if oci_key:
|
|
if not isinstance(oci_key, str):
|
|
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__}"
|
|
),
|
|
)
|
|
oci_key_content = oci_key.replace("\\n", "\n").replace("\r\n", "\n")
|
|
|
|
private_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 OCIError(
|
|
status_code=400,
|
|
message="Private key is required for OCI authentication. Provide either oci_key or oci_key_file.",
|
|
)
|
|
|
|
signature = private_key.sign(
|
|
signing_string.encode("utf-8"),
|
|
padding.PKCS1v15(), # type: ignore[union-attr]
|
|
hashes.SHA256(), # type: ignore[union-attr]
|
|
)
|
|
signature_b64 = base64.b64encode(signature).decode()
|
|
|
|
key_id = f"{oci_tenancy}/{oci_user}/{oci_fingerprint}"
|
|
authorization = (
|
|
'Signature version="1",'
|
|
f'keyId="{key_id}",'
|
|
'algorithm="rsa-sha256",'
|
|
f'headers="{" ".join(signed_header_names)}",'
|
|
f'signature="{signature_b64}"'
|
|
)
|
|
|
|
headers.update(
|
|
{
|
|
"authorization": authorization,
|
|
"date": date,
|
|
"host": host,
|
|
"content-type": content_type,
|
|
"content-length": content_length,
|
|
"x-content-sha256": x_content_sha256,
|
|
}
|
|
)
|
|
return headers, body
|
|
|
|
|
|
def sign_oci_request(
|
|
headers: dict,
|
|
optional_params: dict,
|
|
request_data: dict,
|
|
api_base: str,
|
|
api_key: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
stream: Optional[bool] = None,
|
|
fake_stream: Optional[bool] = None,
|
|
) -> Tuple[dict, bytes]:
|
|
"""
|
|
Route to the appropriate OCI signing method based on what credentials are present.
|
|
|
|
If ``oci_signer`` is in optional_params, use the OCI SDK signer object.
|
|
Otherwise use manual RSA-SHA256 signing with explicit credentials (which can
|
|
also be supplied via OCI_* environment variables).
|
|
|
|
Returns:
|
|
Tuple of (signed_headers, signed_body_bytes)
|
|
"""
|
|
if optional_params.get("oci_signer") is not None:
|
|
return sign_with_oci_signer(headers, optional_params, request_data, api_base)
|
|
return sign_with_manual_credentials(
|
|
headers, optional_params, request_data, api_base
|
|
)
|
|
|
|
|
|
def validate_oci_environment(
|
|
headers: dict,
|
|
optional_params: dict,
|
|
api_key: Optional[str] = None,
|
|
) -> dict:
|
|
"""
|
|
Populate common OCI request headers (content-type, user-agent).
|
|
|
|
Full credential validation is deferred to signing time so that credentials
|
|
supplied via environment variables are resolved at call time rather than
|
|
at construction time.
|
|
"""
|
|
headers.setdefault("content-type", "application/json")
|
|
headers.setdefault("user-agent", f"litellm/{_litellm_version}")
|
|
return headers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON schema utilities for OCI tool definitions
|
|
#
|
|
# OCI Generative AI does not support JSON Schema extensions ($ref, $defs,
|
|
# anyOf). Pydantic v2 emits all three for models with Optional fields or
|
|
# nested schemas. The helpers below are ported from the official
|
|
# langchain-oracle reference implementation so that tool schemas are always
|
|
# valid before they reach the OCI endpoint.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Mapping from JSON Schema type names to Python type names, as expected by
|
|
# the OCI Cohere API's CohereParameterDefinition.type field.
|
|
OCI_JSON_TO_PYTHON_TYPES: Dict[str, str] = {
|
|
"string": "str",
|
|
"number": "float",
|
|
"boolean": "bool",
|
|
"integer": "int",
|
|
"array": "List",
|
|
"object": "Dict",
|
|
"any": "any",
|
|
}
|
|
|
|
|
|
def resolve_oci_schema_refs(schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Inline all ``$ref``/``$defs`` references — OCI does not support JSON Schema ``$ref``."""
|
|
defs = schema.get("$defs", {})
|
|
resolving_stack: set = set()
|
|
|
|
def _resolve(obj: Any) -> Any:
|
|
if isinstance(obj, dict):
|
|
if "$ref" in obj:
|
|
ref = obj["$ref"]
|
|
if ref.startswith("#/$defs/"):
|
|
key = ref.split("/")[-1]
|
|
if key in resolving_stack:
|
|
return {"type": "object"} # break cycles
|
|
resolving_stack.add(key)
|
|
try:
|
|
return _resolve(defs.get(key, obj))
|
|
finally:
|
|
resolving_stack.discard(key)
|
|
return obj # external $ref — leave unchanged
|
|
return {k: _resolve(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [_resolve(item) for item in obj]
|
|
return obj
|
|
|
|
resolved = _resolve(schema)
|
|
if isinstance(resolved, dict):
|
|
resolved.pop("$defs", None)
|
|
return resolved
|
|
|
|
|
|
def resolve_oci_schema_anyof(obj: Any) -> Any:
|
|
"""Resolve Pydantic v2 ``Optional[T]`` → ``anyOf`` patterns.
|
|
|
|
Pydantic v2 emits ``{"anyOf": [{"type": "T"}, {"type": "null"}]}`` for
|
|
``Optional[T]``. OCI models don't understand ``anyOf``, so we pick the
|
|
first non-null branch and merge top-level metadata into it.
|
|
"""
|
|
if isinstance(obj, dict):
|
|
if "anyOf" in obj and "type" not in obj:
|
|
non_null = [
|
|
t
|
|
for t in obj["anyOf"]
|
|
if not (isinstance(t, dict) and t.get("type") == "null")
|
|
]
|
|
if non_null:
|
|
resolved = {**obj, **non_null[0]}
|
|
resolved.pop("anyOf", None)
|
|
return resolve_oci_schema_anyof(resolved)
|
|
return {k: resolve_oci_schema_anyof(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [resolve_oci_schema_anyof(item) for item in obj]
|
|
return obj
|
|
|
|
|
|
def sanitize_oci_schema(schema: Any) -> Any:
|
|
"""Recursively remove OCI-incompatible fields from a JSON schema.
|
|
|
|
Strips ``title`` keys, removes ``None``-valued ``default`` entries,
|
|
normalises ``type: [T, "null"]`` list types, and ensures arrays carry an
|
|
``items`` definition.
|
|
"""
|
|
if isinstance(schema, list):
|
|
return [sanitize_oci_schema(item) for item in schema]
|
|
if not isinstance(schema, dict):
|
|
return schema
|
|
|
|
sanitized: Dict[str, Any] = {}
|
|
for key, value in schema.items():
|
|
if key == "title":
|
|
continue
|
|
if key == "default" and value is None:
|
|
continue
|
|
if key == "type":
|
|
if value == "any":
|
|
sanitized[key] = "object"
|
|
continue
|
|
if isinstance(value, list):
|
|
non_null = [t for t in value if t != "null"]
|
|
sanitized[key] = non_null[0] if non_null else "string"
|
|
continue
|
|
sanitized[key] = sanitize_oci_schema(value)
|
|
|
|
if sanitized.get("type") == "array" and "items" not in sanitized:
|
|
sanitized["items"] = {"type": "object"}
|
|
|
|
required = sanitized.get("required")
|
|
properties = sanitized.get("properties")
|
|
if "required" in sanitized:
|
|
if isinstance(required, list) and isinstance(properties, dict):
|
|
sanitized["required"] = [
|
|
f for f in required if isinstance(f, str) and f in properties
|
|
]
|
|
elif not isinstance(required, list):
|
|
sanitized["required"] = []
|
|
|
|
return sanitized
|
|
|
|
|
|
def enrich_cohere_param_description(
|
|
description: str, param_schema: Dict[str, Any]
|
|
) -> str:
|
|
"""Embed schema constraints into a Cohere parameter description.
|
|
|
|
``CohereParameterDefinition`` only has ``type``, ``description``, and
|
|
``isRequired``. Rich constraints (``enum``, ``format``, ``minimum``,
|
|
``maximum``, ``pattern``) are appended to the description string so the
|
|
model can still see and respect them.
|
|
"""
|
|
parts = [description] if description else []
|
|
if "enum" in param_schema:
|
|
parts.append(f"Allowed values: {param_schema['enum']}")
|
|
if "format" in param_schema:
|
|
parts.append(f"Format: {param_schema['format']}")
|
|
if "minimum" in param_schema or "maximum" in param_schema:
|
|
range_parts = []
|
|
if "minimum" in param_schema:
|
|
range_parts.append(f"min={param_schema['minimum']}")
|
|
if "maximum" in param_schema:
|
|
range_parts.append(f"max={param_schema['maximum']}")
|
|
parts.append(f"Range: {', '.join(range_parts)}")
|
|
if "pattern" in param_schema:
|
|
parts.append(f"Pattern: {param_schema['pattern']}")
|
|
return ". ".join(parts) if parts else ""
|