Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

284 lines
10 KiB
Python

"""
Azure ML Custom Provider Adapter for LiteLLM.
This adapter provides direct OpenAI-compatible API access to Azure ML endpoints
without message transformation, specifically for models like Fara-7B that require
exact OpenAI message formatting.
"""
import json
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
import httpx
from litellm import acompletion, completion
from litellm.llms.custom_llm import CustomLLM
from litellm.types.utils import GenericStreamingChunk, ModelResponse
class AzureMLAdapter(CustomLLM):
"""
Azure ML Adapter for OpenAI-compatible endpoints.
Makes direct HTTP calls to Azure ML foundry inference endpoints
using the OpenAI-compatible API format without transforming messages.
Usage:
model = "azure_ml/Fara-7B"
api_base = "https://foundry-inference-xxx.centralus.inference.ml.azure.com"
api_key = "your-api-key"
response = litellm.completion(
model=model,
messages=[...],
api_base=api_base,
api_key=api_key
)
"""
def __init__(self, **kwargs):
"""Initialize the adapter."""
super().__init__()
self._client: Optional[httpx.Client] = None
self._async_client: Optional[httpx.AsyncClient] = None
def _get_client(self) -> httpx.Client:
"""Get or create sync HTTP client."""
if self._client is None:
self._client = httpx.Client(timeout=600.0)
return self._client
def _get_async_client(self) -> httpx.AsyncClient:
"""Get or create async HTTP client."""
if self._async_client is None:
self._async_client = httpx.AsyncClient(timeout=600.0)
return self._async_client
def _prepare_request(self, **kwargs) -> tuple[str, dict, dict]:
"""
Prepare the HTTP request without transforming messages.
Applies Azure ML workaround: double-encodes function arguments to work around
Azure ML's bug where it parses arguments before validation.
Returns:
Tuple of (url, headers, json_data)
"""
# Extract required params
api_base = kwargs.get("api_base")
api_key = kwargs.get("api_key")
model = kwargs.get("model", "").replace("azure_ml/", "")
messages = kwargs.get("messages", [])
if not api_base:
raise ValueError("api_base is required for azure_ml provider")
if not api_key:
raise ValueError("api_key is required for azure_ml provider")
# Build OpenAI-compatible endpoint URL
base_url = api_base.rstrip("/")
url = f"{base_url}/chat/completions"
# Prepare headers
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
# WORKAROUND for Azure ML bug:
# Azure ML incorrectly parses the arguments field before validation,
# causing it to reject valid JSON strings. We double-encode arguments
# so that after Azure ML's parse, they remain as strings.
messages_copy = []
for message in messages:
msg_copy = message.copy()
# Check if message has tool_calls that need double-encoding
if "tool_calls" in msg_copy:
tool_calls_copy = []
for tool_call in msg_copy["tool_calls"]:
tc_copy = tool_call.copy()
if "function" in tc_copy and "arguments" in tc_copy["function"]:
func_copy = tc_copy["function"].copy()
arguments = func_copy["arguments"]
# If arguments is already a string, double-encode it
if isinstance(arguments, str):
func_copy["arguments"] = json.dumps(arguments)
tc_copy["function"] = func_copy
tool_calls_copy.append(tc_copy)
msg_copy["tool_calls"] = tool_calls_copy
messages_copy.append(msg_copy)
# Prepare request body with double-encoded messages
json_data = {"model": model, "messages": messages_copy}
# Add optional parameters if provided
optional_params = [
"temperature",
"top_p",
"n",
"stream",
"stop",
"max_tokens",
"presence_penalty",
"frequency_penalty",
"logit_bias",
"user",
"response_format",
"seed",
"tools",
"tool_choice",
]
for param in optional_params:
if param in kwargs and kwargs[param] is not None:
json_data[param] = kwargs[param]
return url, headers, json_data
def completion(self, *args, **kwargs) -> ModelResponse:
"""
Synchronous completion method.
Makes a direct HTTP POST to Azure ML's OpenAI-compatible endpoint.
"""
url, headers, json_data = self._prepare_request(**kwargs)
client = self._get_client()
response = client.post(url, headers=headers, json=json_data)
response.raise_for_status()
# Parse response
response_json = response.json()
# Return using litellm's completion with the actual response
return completion(
model=f"azure_ml/{kwargs.get('model', '')}",
mock_response=response_json["choices"][0]["message"]["content"],
messages=kwargs.get("messages", []),
)
async def acompletion(self, *args, **kwargs) -> ModelResponse:
"""
Asynchronous completion method.
Makes a direct async HTTP POST to Azure ML's OpenAI-compatible endpoint.
"""
url, headers, json_data = self._prepare_request(**kwargs)
client = self._get_async_client()
response = await client.post(url, headers=headers, json=json_data)
response.raise_for_status()
# Parse response
response_json = response.json()
# Return using litellm's acompletion with the actual response
return await acompletion(
model=f"azure_ml/{kwargs.get('model', '')}",
mock_response=response_json["choices"][0]["message"]["content"],
messages=kwargs.get("messages", []),
)
def streaming(self, *args, **kwargs) -> Iterator[GenericStreamingChunk]:
"""
Synchronous streaming method.
Makes a streaming HTTP POST to Azure ML's OpenAI-compatible endpoint.
"""
url, headers, json_data = self._prepare_request(**kwargs)
json_data["stream"] = True
client = self._get_client()
with client.stream("POST", url, headers=headers, json=json_data) as response:
response.raise_for_status()
for line in response.iter_lines():
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
if data == "[DONE]":
break
try:
chunk_json = json.loads(data)
delta = chunk_json["choices"][0].get("delta", {})
content = delta.get("content", "")
finish_reason = chunk_json["choices"][0].get("finish_reason")
generic_streaming_chunk: GenericStreamingChunk = {
"finish_reason": finish_reason,
"index": 0,
"is_finished": finish_reason is not None,
"text": content,
"tool_use": None,
"usage": chunk_json.get(
"usage",
{"completion_tokens": 0, "prompt_tokens": 0, "total_tokens": 0},
),
}
yield generic_streaming_chunk
except json.JSONDecodeError:
continue
async def astreaming(self, *args, **kwargs) -> AsyncIterator[GenericStreamingChunk]:
"""
Asynchronous streaming method.
Makes an async streaming HTTP POST to Azure ML's OpenAI-compatible endpoint.
"""
url, headers, json_data = self._prepare_request(**kwargs)
json_data["stream"] = True
client = self._get_async_client()
async with client.stream("POST", url, headers=headers, json=json_data) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
if data == "[DONE]":
break
try:
chunk_json = json.loads(data)
delta = chunk_json["choices"][0].get("delta", {})
content = delta.get("content", "")
finish_reason = chunk_json["choices"][0].get("finish_reason")
generic_streaming_chunk: GenericStreamingChunk = {
"finish_reason": finish_reason,
"index": 0,
"is_finished": finish_reason is not None,
"text": content,
"tool_use": None,
"usage": chunk_json.get(
"usage",
{"completion_tokens": 0, "prompt_tokens": 0, "total_tokens": 0},
),
}
yield generic_streaming_chunk
except json.JSONDecodeError:
continue
def __del__(self):
"""Cleanup HTTP clients."""
if self._client is not None:
self._client.close()
if self._async_client is not None:
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
loop.create_task(self._async_client.aclose())
else:
loop.run_until_complete(self._async_client.aclose())
except Exception:
pass