Files
MoFin/venv/lib/python3.12/site-packages/litellm/llms/oci/chat/generic.py
T
知微 fa45d8aa5f fix: 小果地址统一node122(兼容LAN+EasyTier)
- 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,直连正常
2026-06-30 02:56:35 +08:00

478 lines
17 KiB
Python

"""
OCI Generative AI — Generic-format chat transformation helpers.
Handles message building, tool definition adaptation, non-streaming response
parsing, and streaming chunk parsing for models served with
``apiFormat="GENERIC"`` (e.g. Meta Llama, xAI Grok, Google Gemini).
"""
import datetime
import hashlib
from typing import Any, Dict, List, Optional, Union
import httpx
from pydantic import ValidationError
from litellm.llms.oci.common_utils import (
OCIError,
resolve_oci_schema_anyof,
resolve_oci_schema_refs,
sanitize_oci_schema,
)
from litellm.types.llms.oci import (
OCICompletionResponse,
OCIContentPartUnion,
OCIImageContentPart,
OCIImageUrl,
OCIMessage,
OCIRoles,
OCIStreamChunk,
OCITextContentPart,
OCIToolCall,
OCIToolDefinition,
OCIVendors,
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import (
Delta,
ModelResponse,
ModelResponseStream,
StreamingChoices,
)
from litellm.types.utils import ChatCompletionMessageToolCall, Usage
# Maps OpenAI role names to OCI GENERIC role names.
open_ai_to_generic_oci_role_map: Dict[str, OCIRoles] = {
"system": "SYSTEM",
"user": "USER",
"assistant": "ASSISTANT",
"tool": "TOOL",
}
# ---------------------------------------------------------------------------
# Message building
# ---------------------------------------------------------------------------
def adapt_messages_to_generic_oci_standard_content_message(
role: str, content: Union[str, list]
) -> OCIMessage:
"""Convert a plain-text or multipart content message to OCI format."""
new_content: List[OCIContentPartUnion] = []
if isinstance(content, str):
return OCIMessage(
role=open_ai_to_generic_oci_role_map[role],
content=[OCITextContentPart(text=content)],
toolCalls=None,
toolCallId=None,
)
for content_item in content:
if not isinstance(content_item, dict):
raise OCIError(
status_code=400, message="Each content item must be a dictionary"
)
item_type = content_item.get("type")
if not isinstance(item_type, str):
raise OCIError(
status_code=400,
message="Each content item must have a string `type` field",
)
if item_type not in ["text", "image_url"]:
raise OCIError(
status_code=400,
message=f"Content type `{item_type}` is not supported by OCI",
)
if item_type == "text":
text = content_item.get("text")
if not isinstance(text, str):
raise OCIError(
status_code=400,
message="Content item of type `text` must have a string `text` field",
)
new_content.append(OCITextContentPart(text=text))
elif item_type == "image_url":
image_url = content_item.get("image_url")
if isinstance(image_url, dict):
image_url = image_url.get("url")
if not isinstance(image_url, str):
raise OCIError(
status_code=400,
message="Prop `image_url` must be a string or an object with a `url` property",
)
new_content.append(OCIImageContentPart(imageUrl=OCIImageUrl(url=image_url)))
return OCIMessage(
role=open_ai_to_generic_oci_role_map[role],
content=new_content,
toolCalls=None,
toolCallId=None,
)
def adapt_messages_to_generic_oci_standard_tool_call(
role: str, tool_calls: list
) -> OCIMessage:
"""Convert an assistant tool-call message to OCI format."""
tool_calls_formatted = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
raise OCIError(
status_code=400, message="Each tool call must be a dictionary"
)
if tool_call.get("type") != "function":
raise OCIError(
status_code=400, message="OCI only supports function tool calls"
)
tool_call_id = tool_call.get("id")
if not isinstance(tool_call_id, str):
raise OCIError(status_code=400, message="Tool call `id` must be a string")
tool_function = tool_call.get("function")
if not isinstance(tool_function, dict):
raise OCIError(
status_code=400, message="Tool call `function` must be a dictionary"
)
function_name = tool_function.get("name")
if not isinstance(function_name, str):
raise OCIError(
status_code=400, message="Tool call `function.name` must be a string"
)
arguments = tool_call["function"].get("arguments", "{}")
if not isinstance(arguments, str):
raise OCIError(
status_code=400,
message="Tool call `function.arguments` must be a JSON string",
)
tool_calls_formatted.append(
OCIToolCall(
id=tool_call_id,
type="FUNCTION",
name=function_name,
arguments=arguments,
)
)
return OCIMessage(
role=open_ai_to_generic_oci_role_map[role],
content=None,
toolCalls=tool_calls_formatted,
toolCallId=None,
)
def adapt_messages_to_generic_oci_standard_tool_response(
role: str, tool_call_id: str, content: str
) -> OCIMessage:
"""Convert a tool-result message to OCI format."""
return OCIMessage(
role=open_ai_to_generic_oci_role_map[role],
content=[OCITextContentPart(text=content)],
toolCalls=None,
toolCallId=tool_call_id,
)
def adapt_messages_to_generic_oci_standard(
messages: List[AllMessageValues],
) -> List[OCIMessage]:
"""Convert an OpenAI-format message array to OCI GENERIC format."""
new_messages = []
for message in messages:
role = message["role"]
content = message.get("content")
tool_calls = message.get("tool_calls")
tool_call_id = message.get("tool_call_id")
if role == "assistant" and tool_calls is not None:
if not isinstance(tool_calls, list):
raise OCIError(
status_code=400, message="Message `tool_calls` must be a list"
)
new_messages.append(
adapt_messages_to_generic_oci_standard_tool_call(role, tool_calls)
)
elif role in ["system", "user", "assistant"] and content is not None:
if not isinstance(content, (str, list)):
raise OCIError(
status_code=400,
message="Message `content` must be a string or list of content parts",
)
new_messages.append(
adapt_messages_to_generic_oci_standard_content_message(role, content)
)
elif role == "tool":
if not isinstance(tool_call_id, str):
raise OCIError(
status_code=400,
message="Tool result message must have a string `tool_call_id`",
)
if not isinstance(content, str):
raise OCIError(
status_code=400,
message="Tool result message `content` must be a string",
)
new_messages.append(
adapt_messages_to_generic_oci_standard_tool_response(
role, tool_call_id, content
)
)
return new_messages
# ---------------------------------------------------------------------------
# Tool definition adaptation
# ---------------------------------------------------------------------------
def adapt_tool_definition_to_oci_standard(
tools: List[Dict], vendor: OCIVendors
) -> List[OCIToolDefinition]:
"""Convert OpenAI-format tool definitions to OCI GENERIC format.
Resolves ``$ref``/``$defs`` and ``anyOf`` that the OCI endpoint rejects.
"""
new_tools = []
for tool in tools:
if tool["type"] != "function":
raise OCIError(status_code=400, message="OCI only supports function tools")
tool_function = tool.get("function")
if not isinstance(tool_function, dict):
raise OCIError(
status_code=400, message="Tool `function` must be a dictionary"
)
raw_params = tool_function.get("parameters", {})
resolved_params = sanitize_oci_schema(
resolve_oci_schema_anyof(resolve_oci_schema_refs(raw_params))
)
new_tools.append(
OCIToolDefinition(
type="FUNCTION",
name=tool_function.get("name"),
description=tool_function.get("description", ""),
parameters=resolved_params,
)
)
return new_tools
def _normalize_oci_finish_reason(raw: Optional[str]) -> Optional[str]:
"""Map an OCI-specific finish reason to its OpenAI-standard equivalent.
OCI emits ``COMPLETE`` / ``MAX_TOKENS`` / ``TOOL_CALL(S)`` plus a long tail
of error/cancel reasons (``ERROR``, ``ERROR_TOXIC``, ``ERROR_LIMIT``,
``USER_CANCEL``, ``CONTENT_FILTERED``, ``CANCELLED``, ...). The OpenAI
spec only defines ``stop`` / ``length`` / ``tool_calls`` / ... — anything
else is collapsed to ``"stop"`` so downstream consumers switching on
``finish_reason`` keep working. A ``None`` input passes through unchanged.
"""
if raw is None:
return None
if raw == "COMPLETE":
return "stop"
if raw == "MAX_TOKENS":
return "length"
if raw in ("TOOL_CALL", "TOOL_CALLS"):
return "tool_calls"
return "stop"
def _synthesize_oci_tool_call_id(position: int, name: str, arguments: str) -> str:
"""Deterministic synthetic tool-call id derived from chunk content.
Used as a fallback when OCI omits ``id`` (always the case for the OCI
Cohere protocol, occasionally the case for OCI GENERIC streaming chunks).
A random ``uuid4`` per chunk would cause downstream stream-merging
consumers — which key off the tool-call ``id`` — to treat re-emissions of
the same logical call (e.g. terminal consolidation chunks, retries) as
distinct calls. A content-derived digest stays stable across identical
re-emissions while differing across truly distinct calls.
"""
digest = hashlib.sha256(
f"{position}|{name}|{arguments}".encode("utf-8"),
usedforsecurity=False,
).hexdigest()[:24]
return f"call_{digest}"
def adapt_tools_to_openai_standard(
tools: List[OCIToolCall],
) -> List[ChatCompletionMessageToolCall]:
"""Convert OCI tool-call objects in a response to the OpenAI format."""
return [
ChatCompletionMessageToolCall(
id=tool.id or _synthesize_oci_tool_call_id(i, tool.name, tool.arguments),
type="function",
function={"name": tool.name, "arguments": tool.arguments},
)
for i, tool in enumerate(tools)
]
# ---------------------------------------------------------------------------
# Response parsing
# ---------------------------------------------------------------------------
def handle_generic_response(
json_data: dict,
model: str,
model_response: ModelResponse,
raw_response: httpx.Response,
) -> ModelResponse:
"""Parse a non-streaming GENERIC OCI response into a LiteLLM ModelResponse."""
try:
completion_response = OCICompletionResponse(**json_data)
except (TypeError, ValidationError) as e:
raise OCIError(
message=f"Response cannot be casted to OCICompletionResponse: {str(e)}",
status_code=raw_response.status_code,
)
iso_str = completion_response.chatResponse.timeCreated
dt = datetime.datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
model_response.created = int(dt.timestamp())
model_response.model = completion_response.modelId
if not completion_response.chatResponse.choices:
raise OCIError(
message="OCI response contained no choices",
status_code=raw_response.status_code,
)
response_choice = completion_response.chatResponse.choices[0]
message = model_response.choices[0].message # type: ignore
response_message = response_choice.message
if response_message is not None:
if response_message.content:
# Concatenate all text parts — matches the streaming handler, which
# iterates the full content array. Skips non-text parts (e.g. image
# parts) so a leading non-text part doesn't suppress trailing text.
text: Optional[str] = None
for item in response_message.content:
if isinstance(item, OCITextContentPart):
text = (text or "") + item.text
if text is not None:
message.content = text
if response_message.toolCalls:
message.tool_calls = adapt_tools_to_openai_standard(
response_message.toolCalls
)
model_response.choices[0].finish_reason = _normalize_oci_finish_reason( # type: ignore[union-attr,assignment]
response_choice.finishReason
)
oci_usage = completion_response.chatResponse.usage
reasoning_tokens: Optional[int] = None
if (
oci_usage.completionTokensDetails
and oci_usage.completionTokensDetails.reasoningTokens is not None
):
reasoning_tokens = oci_usage.completionTokensDetails.reasoningTokens
model_response.usage = Usage( # type: ignore[attr-defined]
prompt_tokens=oci_usage.promptTokens,
completion_tokens=oci_usage.completionTokens or 0,
total_tokens=oci_usage.totalTokens,
reasoning_tokens=reasoning_tokens,
)
return model_response
def handle_generic_stream_chunk(dict_chunk: dict) -> ModelResponseStream:
"""Parse a single GENERIC SSE chunk into a LiteLLM ModelResponseStream."""
# OCI streams tool calls progressively — early chunks may omit required fields.
if dict_chunk.get("message") and dict_chunk["message"].get("toolCalls"):
for tool_call in dict_chunk["message"]["toolCalls"]:
tool_call.setdefault("arguments", "")
tool_call.setdefault("id", "")
tool_call.setdefault("name", "")
try:
typed_chunk = OCIStreamChunk(**dict_chunk)
except (TypeError, ValidationError) as e:
raise OCIError(
status_code=500,
message=f"Chunk cannot be parsed as OCIStreamChunk: {str(e)}",
)
if typed_chunk.index is None:
typed_chunk.index = 0
# Emit ``content=None`` rather than ``content=""`` on chunks with no text
# parts (e.g. tool-call-only or keep-alive chunks) so downstream
# stream-mergers that distinguish "no text in this delta" from "an
# explicitly empty text delta" behave correctly.
text: Optional[str] = None
if typed_chunk.message and typed_chunk.message.content:
for item in typed_chunk.message.content:
if isinstance(item, OCITextContentPart):
text = (text or "") + item.text
elif isinstance(item, OCIImageContentPart):
raise OCIError(
status_code=500,
message="OCI returned image content in a streaming response — not supported",
)
else:
raise OCIError(
status_code=500,
message=f"Unsupported content type in OCI streaming response: {item.type}",
)
# Build plain tool-call dicts inline (matching the shape produced by
# ``handle_cohere_stream_chunk``) rather than calling
# ``adapt_tools_to_openai_standard`` and ``model_dump``-ing the typed
# objects. Both code paths feed ``Delta.tool_calls``, so emitting the
# same minimal ``{"id", "type", "function": {"name", "arguments"}}``
# shape keeps downstream stream-mergers behaving identically across
# GENERIC and Cohere chunks.
tool_calls: Optional[List[Dict[str, Any]]] = None
if typed_chunk.message and typed_chunk.message.toolCalls:
tool_calls = [
{
"id": tc.id or _synthesize_oci_tool_call_id(i, tc.name, tc.arguments),
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments,
},
}
for i, tc in enumerate(typed_chunk.message.toolCalls)
]
finish_reason: Optional[str] = _normalize_oci_finish_reason(
typed_chunk.finishReason
)
return ModelResponseStream(
choices=[
StreamingChoices(
index=typed_chunk.index,
delta=Delta(
content=text,
tool_calls=tool_calls,
provider_specific_fields=None,
thinking_blocks=None,
reasoning_content=None,
),
finish_reason=finish_reason,
)
]
)