Skip to content

Commit 8b7c801

Browse files
committed
test(e2e): pin openai_passthrough routing, cost logging, and file list isolation
Five e2e tests over routes a customer drives through the gateway, each one pinning a fix that currently has no live coverage. The dedicated /openai_passthrough prefix used to be swallowed by the provider-scoped /{provider}/v1/files and /{provider}/v1/batches routes, which bound "openai_passthrough" as a provider name and failed inside the gateway before ever reaching OpenAI. Two tests now upload a file and list batches through that prefix and assert OpenAI's own objects come back. Streamed /openai_passthrough/v1/responses and /openai_passthrough/v1/embeddings are relayed to OpenAI but still have to be costed, since the customer budgets against this traffic. Both used to land a row the gateway could not use: the streamed responses call logged a zero-cost row under a random id, and embeddings wrote no row at all. Each test now reconciles the logged spend and token counts against the response the caller was actually served. GET /v1/files narrowed its data to the caller's own rows but left first_id and last_id addressing the shared provider account's page, handing any caller raw provider file ids belonging to other tenants. The new test asserts both cursors address rows in the page the caller can see. ResourceManager.defer now accepts any callable rather than one returning None, so a delete that answers with a response model can be deferred as-is.
1 parent 6fcdea0 commit 8b7c801

7 files changed

Lines changed: 305 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,40 @@ 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+
"""A list page's pagination cursors must address rows in that page.
583+
584+
The proxy fronts one shared provider account, so the upstream page is the
585+
whole organization's. The gateway narrows `data` to the files the caller
586+
owns, and `first_id` / `last_id` have to be narrowed with it: left as the
587+
upstream org's, they hand any caller raw provider file ids belonging to
588+
other tenants, which is the handle the file routes accept.
589+
"""
590+
key = resources.key(user_id=f"e2e-file-list-{unique_marker()}")
591+
592+
listed = unwrap(client.list_files(key=key))
593+
594+
expected_first = listed.data[0].id if listed.data else None
595+
expected_last = listed.data[-1].id if listed.data else None
596+
assert listed.first_id == expected_first, (
597+
f"first_id {listed.first_id!r} is not the first row this caller can see "
598+
f"({expected_first!r}); the page leaked another caller's file id"
599+
)
600+
assert listed.last_id == expected_last, (
601+
f"last_id {listed.last_id!r} is not the last row this caller can see "
602+
f"({expected_last!r}); the page leaked another caller's file id"
603+
)
604+
assert listed.has_more is not True, (
605+
"the page advertises another page, but the proxy never forwards a cursor "
606+
"upstream, so following it re-serves this same page forever"
607+
)
608+
575609
@pytest.mark.covers(
576610
"llm.files.openai.retrieve.nonstream.works",
577611
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 (LIT-5870)"}
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 (LIT-5870)"}
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 (LIT-5870)"}
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 (LIT-5870)"}
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 (LIT-5870)"}
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)