Skip to content

Commit 65b4ac0

Browse files
authored
Merge pull request #37618 from BerriAI/litellm_lit_5870_passthrough_e2e_pins
test(e2e): pin openai_passthrough routing, cost logging, and file list isolation
2 parents 35416c7 + d924226 commit 65b4ac0

7 files changed

Lines changed: 315 additions & 6 deletions

File tree

tests/e2e/batches/batch_client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,15 @@ class FileObject(BaseModel):
4040

4141

4242
class FileList(BaseModel):
43+
"""GET /v1/files page. The cursors are modelled because they are part of the
44+
page's isolation contract: they must address rows in `data`, never rows the
45+
caller was not allowed to see."""
46+
4347
object: str | None = None
4448
data: list[FileObject] = []
49+
first_id: str | None = None
50+
last_id: str | None = None
51+
has_more: bool | None = None
4552

4653

4754
class BatchObject(BaseModel):

tests/e2e/batches/test_batches_e2e.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,41 @@ def test_uploaded_file_appears_in_list(
572572
f"listed file must round-trip the upload purpose, got {match.purpose!r}"
573573
)
574574

575+
@pytest.mark.covers(
576+
"llm.files.openai.list_isolation.nonstream.works",
577+
exercised_on=["files"],
578+
)
579+
def test_list_page_cursors_address_only_the_callers_own_files(
580+
self, client: BatchClient, resources: ResourceManager
581+
) -> None:
582+
"""Pins GitHub issue #36087: a list page's pagination cursors must address
583+
rows in that page.
584+
585+
The proxy fronts one shared provider account, so the upstream page is the
586+
whole organization's. The gateway narrows `data` to the files the caller
587+
owns, and `first_id` / `last_id` have to be narrowed with it: left as the
588+
upstream org's, they hand any caller raw provider file ids belonging to
589+
other tenants, which is the handle the file routes accept.
590+
"""
591+
key = resources.key(user_id=f"e2e-file-list-{unique_marker()}")
592+
593+
listed = unwrap(client.list_files(key=key))
594+
595+
expected_first = listed.data[0].id if listed.data else None
596+
expected_last = listed.data[-1].id if listed.data else None
597+
assert listed.first_id == expected_first, (
598+
f"first_id {listed.first_id!r} is not the first row this caller can see "
599+
f"({expected_first!r}); the page leaked another caller's file id"
600+
)
601+
assert listed.last_id == expected_last, (
602+
f"last_id {listed.last_id!r} is not the last row this caller can see "
603+
f"({expected_last!r}); the page leaked another caller's file id"
604+
)
605+
assert listed.has_more is not True, (
606+
"the page advertises another page, but the proxy never forwards a cursor "
607+
"upstream, so following it re-serves this same page forever"
608+
)
609+
575610
@pytest.mark.covers(
576611
"llm.files.openai.retrieve.nonstream.works",
577612
exercised_on=["files"],

tests/e2e/coverage_registry/llm_conversational.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
- {id: llm.responses.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: responses, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.9 / LIT-4778", rationale: "Responses missing/empty input and missing model are rejected"}
6464
- {id: llm.responses.openai.basic.stream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Streaming via /v1/responses"}
6565
- {id: llm.responses.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "response_api_endpoints/endpoints.py:26", rationale: "Cost logged on responses"}
66+
- {id: llm.responses.openai.passthrough.stream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "A streamed POST /openai_passthrough/v1/responses is costed and keyed by the provider response id; it used to log a zero-cost row under a random id (GitHub issue #36523)"}
6667
- {id: llm.responses.openai.tool_use.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: tool_use, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Tool calls via Responses API"}
6768
- {id: llm.responses.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Vision via Responses API"}
6869
- {id: llm.responses.anthropic.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: responses, route: anthropic, capability: basic, streaming: nonstream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Responses w/ Anthropic translation (smoke)"}

tests/e2e/coverage_registry/llm_nonconversational.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- {id: llm.embeddings.openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_embeddings_endpoint_e2e.py:23", rationale: "Core endpoint, live vector response"}
44
- {id: llm.embeddings.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.3 / LIT-4778", rationale: "Missing model/input on /embeddings return client errors"}
55
- {id: llm.embeddings.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "SPEND_TRACKING_COVERAGE_MATRIX.md:34", rationale: "Cost tracking on embeddings"}
6+
- {id: llm.embeddings.openai.passthrough.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "POST /openai_passthrough/v1/embeddings is costed; the route wrote no spend row at all, so budgets never saw traffic OpenAI was billing for (GitHub issue #36646)"}
67
- {id: llm.embeddings.azure_openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "llms/azure/azure.py", rationale: "Azure embeddings via translation"}
78
- {id: llm.embeddings.bedrock.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "llms/bedrock/embed/embedding.py", rationale: "Bedrock Titan embeddings"}
89
- {id: llm.embeddings.vertex.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "vertex_embeddings/embedding_handler.py", rationale: "Vertex embeddings"}
@@ -13,6 +14,7 @@
1314
- {id: llm.batches.openai.cancel.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch cancel"}
1415
- {id: llm.batches.openai.list.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch list envelope"}
1516
- {id: llm.batches.openai.file_lifecycle.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "File upload/retrieve/delete for batch flow"}
17+
- {id: llm.batches.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "GET /openai_passthrough/v1/batches relays OpenAI's own batch page; the dedicated prefix must not bind as a provider name on the /{provider}/v1/batches route (GitHub issue #36086)"}
1618
- {id: llm.batches.openai_encoded.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Encoded scenario lifecycle"}
1719
- {id: llm.batches.openai_unified.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Unified/managed-id scenario"}
1820
- {id: llm.batches.openai_model_param.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Model-param scenario"}
@@ -29,6 +31,8 @@
2931
- {id: llm.files.openai.retrieve.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File retrieve by id"}
3032
- {id: llm.files.openai.delete.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File delete returns deleted=true"}
3133
- {id: llm.files.openai.list.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File list paginated"}
34+
- {id: llm.files.openai.list_isolation.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "GET /v1/files pagination cursors address only rows the caller owns; on a shared provider account the upstream cursors otherwise hand out other tenants' raw provider file ids (GitHub issue #36087)"}
35+
- {id: llm.files.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "POST/DELETE /openai_passthrough/v1/files relay OpenAI's own file object; the dedicated prefix must not bind as a provider name on the /{provider}/v1/files route (GitHub issue #36086)"}
3236
- {id: llm.files.azure_openai.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:45", rationale: "Azure file upload managed backend"}
3337
- {id: llm.files.vertex.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:52", rationale: "Vertex file upload to GCS"}
3438
- {id: llm.files.bedrock.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:59", rationale: "Bedrock file upload to S3"}

tests/e2e/lifecycle.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,19 @@ class ResourceManager:
5252
"""
5353

5454
client: ResourceClient
55-
_cleanups: List[Callable[[], None]] = field(
55+
_cleanups: List[Callable[[], object]] = field(
5656
default_factory=list
5757
) # mutable-ok: append-only teardown registry
5858

5959
def init(self) -> None:
6060
"""No global setup needed today; present for lifecycle symmetry."""
6161
return None
6262

63-
def defer(self, cleanup: Callable[[], None]) -> None:
64-
"""Register a teardown action for any resource the test just created."""
63+
def defer(self, cleanup: Callable[[], object]) -> None:
64+
"""Register a teardown action for any resource the test just created.
65+
66+
Whatever the action returns is discarded, so a delete that answers with a
67+
response model can be deferred directly."""
6568
self._cleanups.append(cleanup)
6669

6770
def key(self, models: list[str] | None = None, user_id: str | None = "e2e-test-user") -> str:

tests/e2e/llm_translation/passthrough_client.py

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from pydantic import BaseModel, Field
1616

1717
from proxy_client import ProxyClient
18-
from e2e_http import Headers, StreamingResponse
18+
from e2e_http import FileUploadForm, Headers, NoBody, Result, StreamingResponse
1919
from models import ChatMessage
2020

2121

@@ -113,6 +113,76 @@ class OpenAIChatBody(BaseModel):
113113
max_completion_tokens: int = 64
114114

115115

116+
class PassthroughFileObject(BaseModel):
117+
id: str
118+
object: str | None = None
119+
purpose: str | None = None
120+
filename: str | None = None
121+
bytes: int | None = None
122+
123+
124+
class PassthroughFileDeleted(BaseModel):
125+
id: str
126+
deleted: bool
127+
128+
129+
class PassthroughListEntry(BaseModel):
130+
id: str
131+
132+
133+
class ResponsesUsage(BaseModel):
134+
input_tokens: int
135+
output_tokens: int
136+
137+
138+
class ResponsesObject(BaseModel):
139+
id: str
140+
usage: ResponsesUsage | None = None
141+
142+
143+
class ResponsesStreamEvent(BaseModel):
144+
"""One SSE frame of a native Responses stream. Only the terminal frames carry a
145+
`response`, so it stays optional and the deltas validate as themselves."""
146+
147+
type: str
148+
response: ResponsesObject | None = None
149+
150+
151+
def completed_responses_object(result: StreamingResponse) -> ResponsesObject | None:
152+
"""The `response.completed` frame's response object, or None if the stream never
153+
completed. Its `id` is what the spend row is keyed by on this route, and its
154+
usage is what the row is priced from."""
155+
events = (
156+
ResponsesStreamEvent.model_validate_json(payload)
157+
for payload in result.stream_events
158+
)
159+
completed = tuple(
160+
event.response
161+
for event in events
162+
if event.type == "response.completed" and event.response is not None
163+
)
164+
return completed[-1] if completed else None
165+
166+
167+
class OpenAIResponsesBody(BaseModel):
168+
model: str
169+
input: str
170+
stream: bool = False
171+
172+
173+
class OpenAIEmbeddingBody(BaseModel):
174+
model: str
175+
input: str
176+
177+
178+
class PassthroughBatchList(BaseModel):
179+
"""OpenAI's own batch page, relayed verbatim. `object` is required so a body
180+
that is not an OpenAI list fails validation instead of passing vacuously."""
181+
182+
object: str
183+
data: list[PassthroughListEntry]
184+
185+
116186
def _tags_header(tags: list[str] | None) -> str | None:
117187
return ",".join(tags) if tags else None
118188

@@ -196,6 +266,66 @@ def anthropic_message(
196266
stream=stream,
197267
)
198268

269+
# ---- OpenAI file/batch routes under /openai_passthrough -------------
270+
#
271+
# Relayed to OpenAI untouched, which is the whole point of the prefix: the
272+
# customer opts out of the gateway's managed-file handling here.
273+
274+
def openai_passthrough_upload_file(
275+
self, key: str, *, content: bytes, filename: str
276+
) -> Result[PassthroughFileObject]:
277+
return self.proxy.transport.upload(
278+
"/openai_passthrough/v1/files",
279+
headers=self.proxy.transport.bearer(key),
280+
form=FileUploadForm(purpose="batch"),
281+
filename=filename,
282+
content=content,
283+
response_type=PassthroughFileObject,
284+
)
285+
286+
def openai_passthrough_delete_file(
287+
self, key: str, file_id: str
288+
) -> Result[PassthroughFileDeleted]:
289+
return self.proxy.transport.delete(
290+
f"/openai_passthrough/v1/files/{file_id}",
291+
headers=self.proxy.transport.bearer(key),
292+
json=NoBody(),
293+
response_type=PassthroughFileDeleted,
294+
)
295+
296+
def openai_passthrough_list_batches(self, key: str) -> Result[PassthroughBatchList]:
297+
return self.proxy.transport.get(
298+
"/openai_passthrough/v1/batches",
299+
headers=self.proxy.transport.bearer(key),
300+
params=NoBody(),
301+
response_type=PassthroughBatchList,
302+
)
303+
304+
# ---- OpenAI inference routes under /openai_passthrough -------------
305+
#
306+
# Relayed to OpenAI verbatim, but still costed by the gateway: the customer
307+
# budgets against this traffic, so a 200 that logs no spend is money the
308+
# gateway never sees.
309+
310+
def openai_passthrough_responses(
311+
self, key: str, model: str, text: str, *, stream: bool = False
312+
) -> StreamingResponse:
313+
return self.proxy.transport.send(
314+
"/openai_passthrough/v1/responses",
315+
headers=self.proxy.transport.bearer(key),
316+
json=OpenAIResponsesBody(model=model, input=text, stream=stream),
317+
stream=stream,
318+
)
319+
320+
def openai_passthrough_embed(
321+
self, key: str, model: str, text: str
322+
) -> StreamingResponse:
323+
return self.proxy.transport.send(
324+
"/openai_passthrough/v1/embeddings",
325+
headers=self.proxy.transport.bearer(key),
326+
json=OpenAIEmbeddingBody(model=model, input=text),
327+
)
328+
199329
def openai_chat(
200330
self, key: str, model: str, text: str, *, max_completion_tokens: int = 64
201331
) -> StreamingResponse:

0 commit comments

Comments
 (0)