mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
* 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>
190 lines
6.8 KiB
TypeScript
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));
|
|
}
|
|
}
|