fix(azure): price streamed Responses relays and keep a full-URL api_base from doubling the native path

This commit is contained in:
mateo-berri 2026-09-07 21:57:00 -07:00
parent 1de369a1a4
commit 52e9cfe254
6 changed files with 129 additions and 10 deletions

View file

@ -119,7 +119,6 @@ from litellm.types.utils import (
CachingDetails,
CallTypes,
CostBreakdown,
CostResponseTypes,
CustomPricingLiteLLMParams,
DynamicPromptManagementParamLiteral,
EmbeddingResponse,
@ -201,7 +200,7 @@ if TYPE_CHECKING:
from mcp.types import EmbeddedResource, ImageContent, TextContent
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig, LoggedRelayResponse
try:
from litellm_enterprise.enterprise_callbacks.callback_controls import (
EnterpriseCallbackControls,
@ -2363,7 +2362,7 @@ class Logging(LiteLLMLoggingBaseClass):
self,
raw_bytes: list[bytes],
provider_config: "BasePassthroughConfig",
) -> Optional["CostResponseTypes"]:
) -> Optional["LoggedRelayResponse"]:
all_chunks: Final = provider_config._convert_raw_bytes_to_str_lines(raw_bytes)
complete_streaming_response: Final = provider_config.handle_logging_collected_chunks(
all_chunks=all_chunks,

View file

@ -23,7 +23,6 @@ if TYPE_CHECKING:
from httpx import URL
from litellm.llms.base_llm.passthrough.transformation import LoggedRelayResponse
from litellm.types.utils import CostResponseTypes
class RelayedChatRequest(BaseModel):
@ -42,13 +41,29 @@ def _relayed_messages(litellm_logging_obj: Logging) -> Sequence[Mapping[str, obj
return details.request_data.messages if details.request_data else None
RESPONSES_RELAY_SHAPE: Final = RelayShape("/responses", CallTypes.aresponses, ResponsesAPIResponse.model_validate)
OPENAI_RELAY_SHAPES: Final = (
RelayShape("/embeddings", CallTypes.aembedding, EmbeddingResponse.model_validate),
RelayShape("/responses", CallTypes.aresponses, ResponsesAPIResponse.model_validate),
RESPONSES_RELAY_SHAPE,
RelayShape("/images/generations", CallTypes.aimage_generation, ImageResponse.model_validate),
)
def logged_responses_stream(all_chunks: Sequence[str], logging_obj: Logging) -> ResponsesAPIResponse | None:
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
terminal_response: Final = OpenAIResponsesAPIConfig.parse_terminal_response_from_stream_chunks(
all_chunks=list(all_chunks)
)
if terminal_response is None:
return None
logging_obj.call_type = (
RESPONSES_RELAY_SHAPE.call_type.value
) # rebind-ok: routes cost calculation to the relayed shape's pricing path
return terminal_response
class AzurePassthroughConfig(BasePassthroughConfig):
def is_streaming_request(self, endpoint: str, request_data: dict) -> bool:
return bool(request_data.get("stream"))
@ -157,11 +172,13 @@ class AzurePassthroughConfig(BasePassthroughConfig):
model: str,
custom_llm_provider: str,
endpoint: str,
) -> Optional["CostResponseTypes"]:
) -> Optional["LoggedRelayResponse"]:
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.openai_passthrough_logging_handler import (
OpenAIPassthroughLoggingHandler,
)
if f"/{endpoint.strip('/')}".endswith(RESPONSES_RELAY_SHAPE.path_suffix):
return logged_responses_stream(all_chunks, litellm_logging_obj)
if "chat/completions" not in endpoint:
return None

View file

@ -30,7 +30,6 @@ if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse
from litellm.llms.base_llm.passthrough.transformation import LoggedRelayResponse
from litellm.types.utils import CostResponseTypes
EMPTY_QUERY: Final[Mapping[str, object]] = MappingProxyType({})
@ -111,8 +110,8 @@ class AzureAIPassthroughConfig(AzureFoundryModelInfo, BasePassthroughConfig):
if base_target_url is None:
raise ValueError("Azure AI api base not found: set `api_base` on the deployment or AZURE_AI_API_BASE")
root: Final = foundry_root(base_target_url)
native_endpoint: Final = strip_leading_model_segment(endpoint, (model, model_group_from(litellm_params)))
root: Final = foundry_root(base_target_url).removesuffix(f"/{native_endpoint.strip('/')}")
query_params: Final = relay_query_params(
request_query_params, api_version_from(litellm_params), base_target_url
)
@ -200,7 +199,7 @@ class AzureAIPassthroughConfig(AzureFoundryModelInfo, BasePassthroughConfig):
model: str,
custom_llm_provider: str,
endpoint: str,
) -> CostResponseTypes | None:
) -> LoggedRelayResponse | None:
from litellm.llms.azure.passthrough.transformation import AzurePassthroughConfig
return AzurePassthroughConfig().handle_logging_collected_chunks(

View file

@ -179,7 +179,7 @@ class BasePassthroughConfig(BaseLLMModelInfo):
model: str,
custom_llm_provider: str,
endpoint: str,
) -> CostResponseTypes | None:
) -> LoggedRelayResponse | None:
return None
def _convert_raw_bytes_to_str_lines(self, raw_bytes: list[bytes]) -> list[str]:

View file

@ -283,6 +283,53 @@ def test_azure_passthrough_streaming_chunks_for_unknown_endpoint_return_none():
assert response is None
def _azure_responses_stream_chunks(terminal_event: str | None = "response.completed") -> list[str]:
in_progress = {**RESPONSES_BODY, "status": "in_progress", "output": [], "usage": None}
events = [
("response.created", {"type": "response.created", "sequence_number": 0, "response": in_progress}),
(
"response.output_text.delta",
{"type": "response.output_text.delta", "sequence_number": 1, "item_id": "msg_1", "delta": "hi"},
),
] + ([(terminal_event, {"type": terminal_event, "sequence_number": 2, "response": RESPONSES_BODY})] if terminal_event else [])
return [line for name, payload in events for line in (f"event: {name}", _sse_line(payload))]
def test_azure_passthrough_streaming_responses_chunks_are_costed_per_token():
logging_obj = _relay_logging_obj("gpt-4.1-mini")
response = AzurePassthroughConfig().handle_logging_collected_chunks(
all_chunks=_azure_responses_stream_chunks(),
litellm_logging_obj=logging_obj,
model="gpt-4.1-mini",
custom_llm_provider="azure",
endpoint="openai/responses",
)
info = litellm.get_model_info("azure/gpt-4.1-mini")
assert isinstance(response, ResponsesAPIResponse)
assert response.usage.input_tokens == 1000
assert logging_obj.call_type == "aresponses"
assert logging_obj._response_cost_calculator(result=response) == pytest.approx(
1000 * info["input_cost_per_token"] + 100 * info["output_cost_per_token"]
)
def test_azure_passthrough_streaming_responses_without_a_terminal_event_are_not_costed():
logging_obj = _relay_logging_obj("gpt-4.1-mini")
response = AzurePassthroughConfig().handle_logging_collected_chunks(
all_chunks=_azure_responses_stream_chunks(terminal_event=None),
litellm_logging_obj=logging_obj,
model="gpt-4.1-mini",
custom_llm_provider="azure",
endpoint="openai/responses",
)
assert response is None
assert logging_obj.call_type == "allm_passthrough_route"
def _complete_url(request_query_params: dict, litellm_params: dict) -> httpx.URL:
url, _ = AzurePassthroughConfig().get_complete_url(
api_base="https://my-resource.openai.azure.com",

View file

@ -86,6 +86,22 @@ def test_api_base_that_already_ends_in_models_is_cut_back_to_the_foundry_root():
assert base == FOUNDRY_BASE
def test_full_url_api_base_that_already_ends_with_the_native_path_is_not_doubled():
model_router_url = "https://my-resource.cognitiveservices.azure.com/openai/deployments/model-router/chat/completions"
url, base = AzureAIPassthroughConfig().get_complete_url(
api_base=f"{model_router_url}?api-version=2025-01-01-preview",
api_key="key",
model="model_router/model-router",
endpoint="model-router/chat/completions",
request_query_params=None,
litellm_params={"litellm_metadata": {"model_group": "model-router"}},
)
assert str(url) == f"{model_router_url}?api-version=2025-01-01-preview"
assert base == "https://my-resource.cognitiveservices.azure.com/openai/deployments/model-router"
def test_parse_relay_under_a_models_api_base_targets_the_foundry_root():
url, _ = AzureAIPassthroughConfig().get_complete_url(
api_base=f"{FOUNDRY_BASE}/models",
@ -490,3 +506,44 @@ def test_streaming_chat_completion_chunks_are_costed_like_azure():
assert isinstance(response, ModelResponse)
assert response.choices[0].message.content == "hi"
assert response.usage.total_tokens == 4
def test_streaming_responses_chunks_through_a_router_relay_are_costed_like_azure():
completed = {
"type": "response.completed",
"sequence_number": 2,
"response": {
"id": "resp_1",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-5.4-mini",
"output": [
{
"type": "message",
"id": "msg_1",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "hi", "annotations": []}],
}
],
"usage": {"input_tokens": 1000, "output_tokens": 100, "total_tokens": 1100},
},
}
logging_obj = _relay_logging_obj("gpt-5.4-mini", FOUNDRY_BASE)
response = AzureAIPassthroughConfig().handle_logging_collected_chunks(
all_chunks=["event: response.completed", "data: " + json.dumps(completed)],
litellm_logging_obj=logging_obj,
model="gpt-5.4-mini",
custom_llm_provider="azure_ai",
endpoint="gpt/openai/responses",
)
info = litellm.get_model_info("azure_ai/gpt-5.4-mini")
assert response is not None
assert response.usage.output_tokens == 100
assert logging_obj.call_type == "aresponses"
assert logging_obj._response_cost_calculator(result=response) == pytest.approx(
1000 * info["input_cost_per_token"] + 100 * info["output_cost_per_token"]
)