litellm/tests/unit/llms/openai/completion/test_completion_handler.py
devin-ai-integration[bot] a2e334febe
test: migrate local_testing SDK and provider tests out of build_and_test (#45760)
* test: migrate local_testing SDK and provider tests out of build_and_test

* test: cover sagemaker region, hf template and nova paths end to end

Region rows now go through litellm.completion on the sagemaker/ handler that
the legacy tests exercised, the prompt template test mocks the Hugging Face
tokenizer config instead of switching to custom_prompt_dict, streaming tests
assert role, finish reason and body, and Nova single turn and streaming run
through litellm.completion with a recorded request body

* test: tighten migrated integration tests for local OpenAI routes, timeouts and air-gapped boot

Split the catch-all providers migration file by behavior. The fake endpoint
route tests now hit the owned upstream directly, as the legacy harness tests
did. LM Studio asserts the outbound key, path and stripped model. The embedding
timeout test asserts the proxy maps a deployment timeout to a 408 and forwards
the read timeout to the provider client, instead of timing out the test client

The Azure streaming tool-call test also asserts the second identical stream is
served from the Redis cache with the same reassembled tool call

The Docker network test always skipped in CI because the security job has no
litellm/litellm image and no egress to pull one, and it tested a published image
rather than the checkout. It now boots the checkout proxy with every outbound
HTTP connection refused and asserts readiness, the bundled cost map fallback and
that the cost map host is the only egress attempted

* test: tighten the migrated e2e rows for local_testing SDK and provider tests

Drop the duplicated Anthropic covers marker and the Anthropic cell wrongly claimed by
the Bedrock and Vertex cache tests. Keep the file-wide Anthropic model as it is on main.
Send the 2023-06-01 version header to Anthropic through the deployment's extra_headers
so the header actually reaches the provider, and mark the creation-token case 1h then
5m like the legacy test did

Reuse the Bedrock file's own registration for the pydantic response test, swap the
hand-built PNGs for the shared cat.jpg fixture, assert the SageMaker Nova image answer
names the cat and that top_logprobs=3 is honored, and register the SageMaker Nova opt-in
markers the same way as the other opt-in markers

* test: restore lost assertions in migrated test_main tests

Restore the role alternation check in test_completion_anthropic_hanging, the
embedding and streaming response-header checks, the logit_bias drop check and
the gemini system and safety settings checks. Add an azure 429 retry-after
test for test_exception_with_headers, trigger the router fallback with
mock_timeout like the legacy test, and stop the mock timeout tests sleeping 3s

* test: restore lost legacy assertions in migrated vertex ai unit tests

* test: tighten migrated cost, streaming and embedding tests and scope the hosted mock guard

* test: strengthen migrated anthropic, azure, bedrock and cohere provider tests

Assert full request bodies and usage instead of presence checks, restore the
dummy-tool and custom_llm_provider assertions the legacy tests made, add the
async and per-model variants the legacy parametrization covered, split
streamed tool arguments across deltas, and move the Azure chat streaming
tests to the azure.py mirror file

* test: tighten migrated llms provider tests and cover legacy intents lost in the move

Strengthen the migrated fireworks, gemini, mistral, together, openai text completion, openrouter, perplexity and openai error tests so they assert what the legacy tests proved (cost, provider tag, usage-only-on-last-chunk, function_call rebuild, reasoning param, sync/async/stream error fields). Replace the huggingface and jina tests that doubled litellm code with respx end-to-end tests. Add the tests the pre-existing mapped tests never covered: cohere 400 mapping, token-array text completion, hosted_vllm/llamafile/lm_studio env api base embeddings, watsonx embedding auth, ollama ssl_verify client and ollama vision prompt

* test: cover residual legacy paths for anthropic tool streams, watsonx async stream, ollama http images and caller async embedding clients

* test: restore single request assertions in the deep_infra and azure_ai embedding tests

* test: drop a no-op streaming cost flag from the openai streaming cost test

* test: cover residual legacy-test lines in caching, main, utils, replicate, vertex and bedrock invoke

* test: cover streamed audio assembly, choice membership, untranslated responses events and the stream log guard

* test: pin cost rates in unit tests, use sdk for cohere e2e, drop vertex llama e2e

* test: send the vertex maps e2e request through the openai sdk

* test: drop the local_testing jobs that select no tests after the migrations

* test: wait for the streamed cache entry and keep custom Mistral rates test-local

* test: keep untouched Mistral test lines as they were

---------

Co-authored-by: yuneng <yuneng@berri.ai>
2026-10-10 23:14:25 +00:00

376 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) == ("hello", None, None)
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
}