litellm/tests/e2e/ui/helpers/traffic.ts
yuneng-jiang 978aa2816b
test(e2e/ui): stop the suite failing on things that are not regressions (#39063)
* test(e2e/ui): stop the suite failing on things that are not regressions

Five tests in the UI suite fail for reasons that have nothing to do with the
product being broken, which is enough to keep the whole leg red.

Two need a premium proxy and fail hard without one: Regenerate Key renders
disabled when the proxy is unlicensed, and /model/new refuses a team-scoped
deployment. Both now skip without LITELLM_LICENSE, the way three other tests
in this suite already do.

Three consumed a seeded fixture: Delete key, Delete a team and remove a member
each destroyed the row they needed, so the retries CI runs with were guaranteed
to fail and the suite could not run twice against one database. They now create
what they destroy.

Top Virtual Keys ranks by spend and every mock deployment costs $0, so which
keys make the list came down to how ties happened to sort. It now sends its
traffic through a priced deployment and earns its place.

* test(e2e/ui): clean up the fixtures these tests create

Review caught two leaks: the priced deployment the usage test registers and
the user the team-admin test adds both outlived the run, so repeated runs
grew shared state that later routing and rosters can see.

Also brings in the paginated daily-activity read. /user/daily/activity pages
its per-key breakdown and the helper only read the first page, so the usage
test spent its full timeout blaming the rollup for a key the rollup wrote.

* test(e2e/ui): read the licence from the proxy, not the runner

Review pointed out that checking LITELLM_LICENSE in the runner's environment
describes the wrong machine: Playwright can be pointed at a proxy configured
somewhere else, and then the skip either hides coverage or runs a premium
test against an unlicensed target.

The admin session JWT already carries the premium_user claim the dashboard
itself reads to enable these controls, so both skips now use that.

* test(e2e/ui): clean up fixtures on the failure path too

Review caught both cleanups sitting at the end of the test body, where a
failing assertion skips them, and both discarding the response so a refused
delete passed quietly. They move to afterEach and assert the delete landed.

The priced deployment matters most: left behind it keeps its custom pricing
and goes on changing what later runs route and what they cost.

* test(e2e/ui): wait for the priced deployment to become routable

The Top Virtual Keys test registered a priced deployment and sent the key's
traffic through it on the next line, so on the deployed stack it failed with
"no healthy deployments for e2e-usage-priced-...": /model/new had written
the row but the router had not picked it up yet.

Polls a ping until the deployment answers before the test sends the request
it measures, matching what the addModel spec already does for a model added
through the UI. A ping that fails writes no spend log, so the retries cannot
move the ranking this test asserts.

* test(e2e/ui): register fixtures for cleanup before the step that can fail

Review found both helpers handing their id back to the caller to record, with
a failure-prone call in between: the priced deployment was registered after
the routability wait, and the added user after /team/member_add. Either
failing left the resource in the shared database with nothing tracking it.

Both now take the teardown list and add themselves as soon as the resource
exists, so the afterEach removes it however the rest of setup goes.

* test(e2e/ui): resolve the priced deployment for teardown by name

Review pointed out the remaining gap: /model/new can persist the deployment
and still answer non-2xx, and the id was only recorded after the response was
asserted, so that path left it behind with its custom pricing.

The name is now claimed before the request and teardown looks it up in
/model/info, so a create that saved without answering 2xx is still removed and
one that never saved is simply not there.

* test(e2e/ui): claim the member id before creating the user

/user/new can persist the user and still answer non-2xx, and the id is chosen
by the test rather than returned by the proxy, so registering it before the
call is what closes the last create-failure path.

Teardown now skips an id whose user is not there, so claiming it up front
cannot fail a run where the create never landed.

* test(e2e/ui): wait out the router reload when resolving a deployment to delete

/model/info answers from the router, not from the database, and /model/new
catches and logs a failed in-request reload while still answering 2xx. A
deployment can therefore be persisted and absent from the listing until the
next reload, which is where teardown was giving up and leaking it.

Teardown now retries the lookup for a little over one
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS before treating the name as never
persisted, so the only names it skips are the ones that really are not there.

* test(e2e/ui): prove a stored credential survives a config reload before using it

The Test Connect assertion has been failing intermittently on the full-suite
runs. Artifacts from litellm-e2e-ui build 165 show the UI sending
litellm_credential_name and the proxy answering with the credential unapplied:
raw_request_api_base was https://api.openai.com/v1/ rather than the mock base
the credential carries, and the call died on an upstream 404 for the model. The
same credential had resolved on three probes eight seconds earlier.

The proxy's periodic credential refresh takes a database snapshot, prunes any
in-memory credential missing from it, then re-adds the snapshot. A credential
created while that is in flight gets pruned and stays gone until the next tick,
and load_credentials_from_list fails open onto the ambient key, so nothing in
the error names the credential.

The existing pre-check asked for three consecutive probe successes, but they
completed in under a second, so they could not span a refresh. Space them so
the run covers a whole interval, which is what proves the credential survived a
refresh and is therefore stable.

* test(e2e/ui): find a database-only deployment through the search listing

/model/info answers from the router, so a deployment that reached the database
while /model/new's in-request reload failed is invisible there, and waiting on
the next reload only helps if reconciliation eventually picks it up.

/v2/model/info?search= runs a bounded query against the model table and
deliberately returns rows the router does not hold, so it resolves those
deployments to the id /model/delete needs. Falling back to it removes the wait
as well: absent from both listings now means the deployment never persisted.

* test(e2e/ui): delete the temporary member without a lookup that can skip it

Teardown asked /user/info first and treated any non-2xx as absence, so a
transient failure on the lookup silently skipped the delete and left the user
behind, which is the leak the claimed id was meant to close.

/user/delete answers 404 for an id that is not there, so it can carry both
cases on its own: 404 means the create never persisted, and anything else that
is not 2xx now fails the teardown instead of passing quietly.

* test(e2e/ui): reach the database fallback when the router listing fails

Asserting on /model/info threw before the fallback could run, so a failure on
the router-backed listing aborted teardown and left the deployment persisted,
which is the leak the fallback was added to close.

The router listing is best-effort now: an unreadable response just falls
through to the search-backed one. That listing is the authoritative answer to
whether the deployment exists, so it is the one that has to be readable, and a
name missing from it is a create that never persisted.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-01 12:34:57 -07:00

190 lines
6.8 KiB
TypeScript

import { APIRequestContext, expect } from "@playwright/test";
/** Model names served by fixtures/config.yml, both backed by the mock LLM server. */
export const CHAT_MODEL_A = "fake-openai-gpt-4";
export const CHAT_MODEL_B = "fake-anthropic-claude";
/** The deployment each of those models routes to, as spend logs and usage breakdowns name it. */
export const DEPLOYMENT_MODEL_A = "openai/fake-gpt-4";
export const DEPLOYMENT_MODEL_B = "openai/fake-claude";
/** The only completion text fixtures/mock_llm_server/server.py ever returns. */
export const MOCK_RESPONSE_TEXT = "This is a mock response.";
export const masterKey = (): string => process.env.LITELLM_MASTER_KEY || "sk-1234";
export const rootPath = (): string => process.env.SERVER_ROOT_PATH ?? "";
interface ChatOptions {
model: string;
prompt: string;
apiKey?: string;
/** Sent as `user`, which lands in the spend log's end_user column. */
endUser?: string;
}
/** POST /v1/chat/completions and return the completion id (the Logs Request ID). */
export async function sendChatCompletion(request: APIRequestContext, opts: ChatOptions): Promise<string> {
const res = await request.post(`${rootPath()}/v1/chat/completions`, {
headers: {
Authorization: `Bearer ${opts.apiKey ?? masterKey()}`,
"Content-Type": "application/json",
},
data: {
model: opts.model,
messages: [{ role: "user", content: opts.prompt }],
...(opts.endUser ? { user: opts.endUser } : {}),
},
});
expect(res.ok(), `chat completion for ${opts.model} failed (${res.status()}): ${await res.text()}`).toBe(true);
const body = await res.json();
expect(body.choices?.[0]?.message?.content).toContain(MOCK_RESPONSE_TEXT);
return body.id as string;
}
/** `key` is the sk- value to authenticate with; `token` is its hash, which spend aggregates are keyed by. */
export async function createVirtualKey(
request: APIRequestContext,
data: Record<string, unknown> = {},
): Promise<{ key: string; token: string; alias?: string }> {
const res = await request.post(`${rootPath()}/key/generate`, {
headers: {
Authorization: `Bearer ${masterKey()}`,
"Content-Type": "application/json",
},
data,
});
expect(res.ok(), `key generate failed (${res.status()}): ${await res.text()}`).toBe(true);
const body = await res.json();
return {
key: body.key as string,
token: (body.token ?? body.token_id) as string,
alias: body.key_alias as string | undefined,
};
}
/** Spend logs are flushed on a timer, so an assertion straight after a completion races the writer. */
export async function waitForSpendLog(
request: APIRequestContext,
requestId: string,
timeoutMs = 60_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastStatus = 0;
while (Date.now() < deadline) {
const res = await request.get(`${rootPath()}/spend/logs?request_id=${encodeURIComponent(requestId)}`, {
headers: { Authorization: `Bearer ${masterKey()}` },
});
lastStatus = res.status();
if (res.ok()) {
const body = await res.json();
const rows = Array.isArray(body) ? body : (body?.data ?? []);
if (rows.length > 0) {
return;
}
}
await new Promise((r) => setTimeout(r, 2_000));
}
throw new Error(`spend log for request ${requestId} never appeared (last /spend/logs status ${lastStatus})`);
}
export async function waitForSpendLogByPrompt(
request: APIRequestContext,
prompt: string,
timeoutMs = 60_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
let lastStatus = 0;
while (Date.now() < deadline) {
const res = await request.get(`${rootPath()}/spend/logs`, {
headers: { Authorization: `Bearer ${masterKey()}` },
});
lastStatus = res.status();
if (res.ok()) {
const rows: { request_id?: string; messages?: unknown; proxy_server_request?: unknown }[] = await res.json();
const row = (Array.isArray(rows) ? rows : []).find(
(candidate) =>
JSON.stringify(candidate.messages ?? "").includes(prompt) ||
JSON.stringify(candidate.proxy_server_request ?? "").includes(prompt),
);
if (row?.request_id) {
return row.request_id;
}
}
await new Promise((r) => setTimeout(r, 2_000));
}
throw new Error(`no spend log row carrying prompt ${prompt} appeared (last /spend/logs status ${lastStatus})`);
}
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
interface DailyActivityKey {
metrics?: { api_requests?: number };
}
interface DailyActivityPage {
results?: { breakdown?: { api_keys?: Record<string, DailyActivityKey> } }[];
metadata?: { total_pages?: number };
}
const requestsOnPage = (body: DailyActivityPage, keyToken: string): number =>
(body.results ?? []).reduce((sum, day) => sum + (day.breakdown?.api_keys?.[keyToken]?.metrics?.api_requests ?? 0), 0);
/**
* The route paginates its per-key breakdown. Reading only the first page finds a key while the
* database is small and stops finding it once a run has generated more keys than one page holds,
* which reads as "the rollup is not running" when the rollup is fine.
*/
async function keyRequestsInDailyActivity(
request: APIRequestContext,
query: string,
keyToken: string,
page = 1,
seen = 0,
): Promise<number> {
const res = await request.get(`${rootPath()}/user/daily/activity?${query}&page=${page}`, {
headers: { Authorization: `Bearer ${masterKey()}` },
});
if (!res.ok()) {
return seen;
}
const body = (await res.json()) as DailyActivityPage;
const total = seen + requestsOnPage(body, keyToken);
return page >= (body.metadata?.total_pages ?? 1)
? total
: keyRequestsInDailyActivity(request, query, keyToken, page + 1, total);
}
/**
* The Usage page reads /user/daily/activity, a rollup written by a background job, and fetches it once
* on mount. Navigating before the rollup lands leaves a stale render that never refreshes.
*
* The rollup lands request by request, so waiting only for the key to appear leaves a caller that
* sent several requests reading a partial count. Pass `minRequests` to wait for all of them.
*/
export async function waitForKeyInDailyActivity(
request: APIRequestContext,
keyToken: string,
minRequests = 1,
timeoutMs = 120_000,
): Promise<void> {
const now = new Date();
const start = new Date(now);
start.setDate(start.getDate() - 7);
const query = `start_date=${isoDay(start)}&end_date=${isoDay(now)}`;
const deadline = Date.now() + timeoutMs;
for (;;) {
const seen = await keyRequestsInDailyActivity(request, query, keyToken);
if (seen >= minRequests) {
return;
}
if (Date.now() >= deadline) {
throw new Error(
`key ${keyToken} reached ${seen} of ${minRequests} requests in /user/daily/activity across every page; ` +
"the daily spend rollup may not be running",
);
}
await new Promise((r) => setTimeout(r, 3_000));
}
}