mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
* fix: Send the include_usage chunk with an empty choices array Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * test: Skip chunks with no choices when reading streams Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Strip usage from plain-iterator async stream chunks Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * test: Expect empty choices on the usage chunk in the gemma tracing test Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix(streaming): send the empty-choices usage chunk only to callers that asked for include_usage The usage chunk now has choices=[] only when the caller set stream_options.include_usage itself. When the OpenAI handler, the proxy's always_include_stream_usage setting, or the harness added include_usage on the caller's behalf, the chunk keeps its legacy single empty-delta choice, so clients that read choices[0] on every chunk keep working. The proxy and harness mark their injection with the private _litellm_stream_usage_injected flag, and the proxy scrubs a client-sent copy. Plain-iterator streams keep usage on their chunk when usage was not requested, and the LLM Shield trailing flush ignores chunks with no choices. * fix(proxy): mark the default stream usage injection so hooks keep the legacy usage chunk shape The proxy's default strip-mode injection adds include_usage for clients that did not ask and drops the usage chunk before it reaches them, but streaming iterator hooks still saw it with choices=[]. Marking that injection with _litellm_stream_usage_injected keeps the old single empty choice for them. * fix(router): treat deployment and router-default stream_options as injected, and drop the client flag on the queue route * refactor(router): move the stream usage injection check into router_utils * fix(router): read stream_options from pydantic deployment params when marking injected usage Update the mantle, openinference, and text-completion integration tests to expect the include_usage chunk with empty choices. * fix(proxy): keep the stream usage injection marker out of stored request bodies * test(streaming): cover who asked for stream usage on chat, queue, deployment and spend log paths * test: type the queue flag test, pin the logging start time, and build the deployment usage config without mutation --------- Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""
|
|
Tests that client headers are forwarded to the provider on the OpenAI
|
|
text completion path.
|
|
|
|
Regression tests for https://github.com/BerriAI/litellm/issues/27410
|
|
"""
|
|
|
|
|
|
import json
|
|
from typing import Final
|
|
|
|
import pytest
|
|
import respx
|
|
from httpx import Response
|
|
|
|
|
|
import litellm
|
|
from litellm import atext_completion, text_completion
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_env(monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-fake-key")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_completions_endpoint():
|
|
return respx.post("https://api.openai.com/v1/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "cmpl-test123",
|
|
"object": "text_completion",
|
|
"created": 1677652288,
|
|
"model": "gpt-3.5-turbo-instruct",
|
|
"choices": [
|
|
{
|
|
"text": "hi",
|
|
"index": 0,
|
|
"logprobs": None,
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 1,
|
|
"total_tokens": 2,
|
|
},
|
|
},
|
|
)
|
|
)
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_forwards_client_headers_to_provider(mock_completions_endpoint):
|
|
text_completion(
|
|
model="gpt-3.5-turbo-instruct",
|
|
prompt="hello",
|
|
max_tokens=5,
|
|
headers={"x-mycorp-llmcall-id": "abc-123"},
|
|
)
|
|
|
|
request_headers = mock_completions_endpoint.calls.last.request.headers
|
|
assert request_headers["x-mycorp-llmcall-id"] == "abc-123"
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_forwards_extra_headers_to_provider(mock_completions_endpoint):
|
|
text_completion(
|
|
model="gpt-3.5-turbo-instruct",
|
|
prompt="hello",
|
|
max_tokens=5,
|
|
extra_headers={"x-mycorp-llmcall-id": "abc-123"},
|
|
)
|
|
|
|
request_headers = mock_completions_endpoint.calls.last.request.headers
|
|
assert request_headers["x-mycorp-llmcall-id"] == "abc-123"
|
|
|
|
|
|
@respx.mock
|
|
async def test_acompletion_forwards_client_headers_to_provider(
|
|
mock_completions_endpoint, monkeypatch
|
|
):
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
await atext_completion(
|
|
model="gpt-3.5-turbo-instruct",
|
|
prompt="hello",
|
|
max_tokens=5,
|
|
headers={"x-mycorp-llmcall-id": "abc-123"},
|
|
)
|
|
|
|
request_headers = mock_completions_endpoint.calls.last.request.headers
|
|
assert request_headers["x-mycorp-llmcall-id"] == "abc-123"
|
|
|
|
|
|
@pytest.fixture
|
|
def rejected_completions_endpoint():
|
|
return respx.post("https://api.openai.com/v1/completions").mock(
|
|
return_value=Response(
|
|
400,
|
|
json={"error": {"message": "bad prompt", "type": "invalid_request_error"}},
|
|
headers={"x-request-id": "req-1"},
|
|
)
|
|
)
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_surfaces_the_status_and_headers_of_a_rejected_request(rejected_completions_endpoint):
|
|
with pytest.raises(litellm.BadRequestError) as rejected:
|
|
text_completion(model="gpt-3.5-turbo-instruct", prompt="hello")
|
|
|
|
assert rejected.value.status_code == 400
|
|
assert rejected.value.litellm_response_headers["x-request-id"] == "req-1"
|
|
|
|
|
|
@respx.mock
|
|
def test_streaming_completion_surfaces_the_status_and_headers_of_a_rejected_request(rejected_completions_endpoint):
|
|
with pytest.raises(litellm.BadRequestError) as rejected:
|
|
list(text_completion(model="gpt-3.5-turbo-instruct", prompt="hello", stream=True))
|
|
|
|
assert rejected.value.status_code == 400
|
|
assert rejected.value.litellm_response_headers["x-request-id"] == "req-1"
|
|
|
|
|
|
@respx.mock
|
|
async def test_acompletion_surfaces_the_status_and_headers_of_a_rejected_request(
|
|
rejected_completions_endpoint, monkeypatch
|
|
):
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
with pytest.raises(litellm.BadRequestError) as rejected:
|
|
await atext_completion(model="gpt-3.5-turbo-instruct", prompt="hello")
|
|
|
|
assert rejected.value.status_code == 400
|
|
assert rejected.value.litellm_response_headers["x-request-id"] == "req-1"
|
|
|
|
|
|
@respx.mock
|
|
async def test_async_streaming_completion_reports_an_error_event_sent_mid_stream(monkeypatch):
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
respx.post("https://api.openai.com/v1/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
text='data: {"error": {"message": "upstream overloaded", "type": "server_error"}}\n\n',
|
|
headers={"content-type": "text/event-stream"},
|
|
)
|
|
)
|
|
with pytest.raises(litellm.InternalServerError) as failed:
|
|
async for _ in await atext_completion(model="gpt-3.5-turbo-instruct", prompt="hello", stream=True):
|
|
pass
|
|
|
|
assert failed.value.status_code == 500
|
|
assert "upstream overloaded" in str(failed.value)
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_openai_prompt_array_sends_both_prompts() -> None:
|
|
route: Final = respx.post("https://api.openai.com/v1/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "cmpl-test123",
|
|
"object": "text_completion",
|
|
"created": 1677652288,
|
|
"model": "gpt-3.5-turbo-instruct",
|
|
"choices": [
|
|
{"text": "first answer", "index": 0, "logprobs": None, "finish_reason": "stop"},
|
|
{"text": "second answer", "index": 1, "logprobs": None, "finish_reason": "stop"},
|
|
],
|
|
"usage": {"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = text_completion(
|
|
model="gpt-3.5-turbo-instruct",
|
|
prompt=["first prompt", "second prompt"],
|
|
max_tokens=5,
|
|
)
|
|
|
|
body: Final = json.loads(route.calls.last.request.read())
|
|
assert body["prompt"] == ["first prompt", "second prompt"]
|
|
assert [choice.text for choice in response.choices] == ["first answer", "second answer"]
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_text_003_token_prompt_array_returns_one_choice_per_prompt() -> None:
|
|
token_prompts: Final = [[2061, 338, 262, 6193, 287, 14362, 30], [2437, 318, 9502, 30]]
|
|
route: Final = respx.post("https://api.fireworks.ai/inference/v1/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "cmpl-token-prompts",
|
|
"object": "text_completion",
|
|
"created": 1677652288,
|
|
"model": "accounts/fireworks/models/glm-5p3-flash",
|
|
"choices": [
|
|
{"text": "sunny", "index": 0, "logprobs": None, "finish_reason": "length"},
|
|
{"text": "rainy", "index": 1, "logprobs": None, "finish_reason": "length"},
|
|
],
|
|
"usage": {"prompt_tokens": 11, "completion_tokens": 2, "total_tokens": 13},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = text_completion(
|
|
model="text-completion-openai/accounts/fireworks/models/glm-5p3-flash",
|
|
api_base="https://api.fireworks.ai/inference/v1",
|
|
api_key="fireworks-test-key",
|
|
prompt=token_prompts,
|
|
max_tokens=5,
|
|
)
|
|
|
|
body: Final = json.loads(route.calls.last.request.read())
|
|
assert body["prompt"] == token_prompts
|
|
assert body["model"] == "accounts/fireworks/models/glm-5p3-flash"
|
|
assert [(choice.index, choice.text) for choice in response.choices] == [(0, "sunny"), (1, "rainy")]
|
|
|
|
|
|
@respx.mock
|
|
def test_text_completion_with_echo_returns_prompt_and_token_logprobs() -> None:
|
|
route: Final = respx.post("https://api.openai.com/v1/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
json={
|
|
"id": "cmpl-test123",
|
|
"object": "text_completion",
|
|
"created": 1677652288,
|
|
"model": "gpt-3.5-turbo-instruct",
|
|
"choices": [
|
|
{
|
|
"text": "hello world",
|
|
"index": 0,
|
|
"logprobs": {"tokens": ["hello", " world"], "token_logprobs": [-0.1, -0.2]},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = text_completion(
|
|
model="gpt-3.5-turbo-instruct",
|
|
prompt="hello",
|
|
max_tokens=1,
|
|
echo=True,
|
|
logprobs=1,
|
|
)
|
|
|
|
body: Final = json.loads(route.calls.last.request.read())
|
|
assert body["echo"] is True
|
|
assert response.choices[0].text == "hello world"
|
|
assert response.choices[0].logprobs.token_logprobs == [-0.1, -0.2]
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_openai_engine_selects_the_engine_model(mock_completions_endpoint) -> None:
|
|
response: Final = text_completion(engine="gpt-3.5-turbo-instruct", prompt="hello", max_tokens=5)
|
|
|
|
request_body: Final = json.loads(mock_completions_endpoint.calls.last.request.read())
|
|
assert request_body["model"] == "gpt-3.5-turbo-instruct"
|
|
assert response.model == "gpt-3.5-turbo-instruct"
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_openai_engine_and_model_uses_the_explicit_model(mock_completions_endpoint) -> None:
|
|
response: Final = text_completion(
|
|
model="gpt-3.5-turbo-instruct",
|
|
engine="unused-engine",
|
|
prompt="hello",
|
|
max_tokens=5,
|
|
)
|
|
|
|
request_body: Final = json.loads(mock_completions_endpoint.calls.last.request.read())
|
|
assert request_body["model"] == "gpt-3.5-turbo-instruct"
|
|
assert response.model == "gpt-3.5-turbo-instruct"
|
|
|
|
|
|
@respx.mock
|
|
async def test_async_text_completion_stream_returns_text_and_one_finish_reason(monkeypatch):
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx.post("https://api.openai.com/v1/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
text=(
|
|
'data: {"id":"cmpl-stream","object":"text_completion","created":1,"model":"gpt-3.5-turbo-instruct","choices":[{"index":0,"text":"hello","finish_reason":null}]}\n\n'
|
|
'data: {"id":"cmpl-stream","object":"text_completion","created":1,"model":"gpt-3.5-turbo-instruct","choices":[{"index":0,"text":"","finish_reason":"stop"}]}\n\n'
|
|
"data: [DONE]\n\n"
|
|
),
|
|
headers={"content-type": "text/event-stream"},
|
|
)
|
|
)
|
|
|
|
stream: Final = await atext_completion(
|
|
model="gpt-3.5-turbo-instruct", prompt="say hello", stream=True, max_tokens=5
|
|
)
|
|
chunks: Final = [chunk async for chunk in stream]
|
|
|
|
assert [chunk.choices[0].text for chunk in chunks if chunk.choices[0].text] == ["hello"]
|
|
assert tuple(
|
|
chunk.choices[0].finish_reason
|
|
for chunk in chunks
|
|
if chunk.choices[0].finish_reason is not None
|
|
) == ("stop",)
|
|
assert len(route.calls) == 1
|
|
|
|
|
|
@respx.mock
|
|
async def test_async_text_completion_chat_model_stream_builds_chat_chunks(monkeypatch):
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
text=(
|
|
'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":null}]}\n\n'
|
|
'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk","created":1,"model":"gpt-3.5-turbo","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'
|
|
"data: [DONE]\n\n"
|
|
),
|
|
headers={"content-type": "text/event-stream"},
|
|
)
|
|
)
|
|
|
|
stream: Final = await atext_completion(
|
|
model="gpt-3.5-turbo", prompt="say hello", stream=True, max_tokens=5
|
|
)
|
|
chunks: Final = [chunk async for chunk in stream]
|
|
response: Final = litellm.stream_chunk_builder(chunks=chunks)
|
|
|
|
assert response.choices[0].text == "hello"
|
|
assert (response.usage.prompt_tokens, response.usage.completion_tokens) == (0, 1)
|
|
assert litellm.completion_cost(
|
|
completion_response=response,
|
|
custom_cost_per_token={"input_cost_per_token": 5e-07, "output_cost_per_token": 1.5e-06},
|
|
) == pytest.approx(1 * 1.5e-06)
|
|
assert tuple(
|
|
chunk.choices[0].finish_reason
|
|
for chunk in chunks
|
|
if chunk.choices[0].finish_reason is not None
|
|
) == ("stop",)
|
|
assert len(route.calls) == 1
|
|
|
|
|
|
@respx.mock
|
|
def test_text_completion_stream_forwards_stream_options_and_usage(monkeypatch):
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx.post("https://api.openai.com/v1/completions").mock(
|
|
return_value=Response(
|
|
200,
|
|
text=(
|
|
'data: {"id":"cmpl-stream","object":"text_completion","created":1,"model":"gpt-3.5-turbo-instruct","choices":[{"index":0,"text":"hello","finish_reason":null}]}\n\n'
|
|
'data: {"id":"cmpl-stream","object":"text_completion","created":1,"model":"gpt-3.5-turbo-instruct","choices":[],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}\n\n'
|
|
"data: [DONE]\n\n"
|
|
),
|
|
headers={"content-type": "text/event-stream"},
|
|
)
|
|
)
|
|
|
|
chunks: Final = list(
|
|
text_completion(
|
|
model="gpt-3.5-turbo-instruct",
|
|
prompt="say hello",
|
|
stream=True,
|
|
stream_options={"include_usage": True},
|
|
)
|
|
)
|
|
|
|
assert tuple(chunk.choices[0].text for chunk in chunks[:-1]) == ("hello", None)
|
|
assert chunks[-1].choices == []
|
|
assert tuple(chunk.usage for chunk in chunks[:-1]) == (None, None)
|
|
assert (chunks[-1].usage.prompt_tokens, chunks[-1].usage.completion_tokens, chunks[-1].usage.total_tokens) == (
|
|
2,
|
|
1,
|
|
3,
|
|
)
|
|
assert json.loads(route.calls[0].request.content)["stream_options"] == {
|
|
"include_usage": True
|
|
}
|