mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
test: add extension and browser integration contracts
Some checks failed
Some checks failed
Adds integration contracts for MCP lifecycle, protocol errors and OAuth configuration, A2A wire versions, the OpenAI consumer path, persisted toolsets, callback delivery, guardrail effects, configured prices, the filtered spend ledger, and a CircleCI-owned browser flow for project detachment, with the ASGI, browser-state, client and MCP helpers they use. Consolidates the eleven commits previously stacked on litellm_integration_providers onto its rebased tip
This commit is contained in:
parent
fa5d31a837
commit
c00f1b4a5c
27 changed files with 1761 additions and 25 deletions
|
|
@ -257,7 +257,7 @@ commands:
|
|||
- install_rust
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
- v3-integration-uv-cache-{{ checksum "uv.lock" }}
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: |
|
||||
|
|
@ -266,7 +266,7 @@ commands:
|
|||
- save_cache:
|
||||
paths:
|
||||
- ~/.cache/uv
|
||||
key: v1-uv-cache-{{ checksum "uv.lock" }}
|
||||
key: v3-integration-uv-cache-{{ checksum "uv.lock" }}
|
||||
|
||||
jobs:
|
||||
# Add Windows testing job
|
||||
|
|
@ -2955,6 +2955,32 @@ jobs:
|
|||
working_directory: ~/project
|
||||
steps:
|
||||
- setup_litellm_test_deps
|
||||
- when:
|
||||
condition:
|
||||
equal: [browser, << parameters.suite >>]
|
||||
steps:
|
||||
- install_node
|
||||
- restore_cache:
|
||||
keys:
|
||||
- integration-ui-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
|
||||
- run:
|
||||
name: Install locked browser dependencies
|
||||
command: |
|
||||
cd ui/litellm-dashboard
|
||||
npm ci
|
||||
cd ../../tests/e2e/ui
|
||||
npm ci
|
||||
sudo env PATH="$PATH" DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l \
|
||||
timeout --signal=TERM --kill-after=20s 6m node node_modules/@playwright/test/cli.js install-deps chromium
|
||||
timeout --signal=TERM --kill-after=20s 3m node node_modules/@playwright/test/cli.js install chromium
|
||||
- save_cache:
|
||||
key: integration-ui-v3-{{ checksum "ui/litellm-dashboard/package-lock.json" }}-{{ checksum "tests/e2e/ui/package-lock.json" }}
|
||||
paths:
|
||||
- ~/.npm
|
||||
- ~/.cache/ms-playwright
|
||||
- run:
|
||||
name: Build the candidate dashboard
|
||||
command: cd ui/litellm-dashboard && NEXT_TELEMETRY_DISABLED=1 npm run build
|
||||
- start_postgres:
|
||||
image: postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
|
||||
- start_redis
|
||||
|
|
@ -2983,7 +3009,7 @@ workflows:
|
|||
name: integration-<< matrix.suite >>
|
||||
matrix:
|
||||
parameters:
|
||||
suite: [management, accounting, database, providers]
|
||||
suite: [management, accounting, database, providers, extensions, browser]
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${GITHUB_ACTIONS:-}" = true ]; then
|
||||
echo "Integration contracts are owned by CircleCI" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
suite="${1:?integration suite required}"
|
||||
results="test-results/integration-${suite}"
|
||||
mkdir -p "$results"
|
||||
|
|
@ -65,7 +70,13 @@ export INTEGRATION_PROXY_URL=http://127.0.0.1:4000
|
|||
export INTEGRATION_PEER_URL=""
|
||||
export INTEGRATION_UPSTREAM_URL=http://127.0.0.1:8190
|
||||
export INTEGRATION_MASTER_KEY="$LITELLM_MASTER_KEY"
|
||||
export INTEGRATION_SEED="$((16#$(git rev-parse --short=8 HEAD)))"
|
||||
export LITELLM_UI_PATH="$PWD/litellm/proxy/_experimental/out"
|
||||
if [ "$suite" = browser ]; then
|
||||
export LITELLM_UI_PATH="$PWD/ui/litellm-dashboard/out"
|
||||
test -f "$LITELLM_UI_PATH/index.html"
|
||||
fi
|
||||
export INTEGRATION_SEED="$(.venv/bin/python -c 'import hashlib,os; print(int(hashlib.sha256((os.environ.get("CIRCLE_SHA1", "local") + os.environ.get("CIRCLE_WORKFLOW_ID", "local")).encode()).hexdigest()[:8],16))')"
|
||||
export INTEGRATION_ORDER_SEED="$INTEGRATION_SEED"
|
||||
|
||||
uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma > "$results/prisma-generate.log" 2>&1
|
||||
|
||||
|
|
@ -102,7 +113,7 @@ start_proxy() {
|
|||
local log_name="$2"
|
||||
setsid env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" INTEGRATION_RUN_ID="$integration_identity" \
|
||||
DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" \
|
||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" LITELLM_SALT_KEY="$LITELLM_SALT_KEY" LITELLM_UI_PATH="$LITELLM_UI_PATH" PROXY_BASE_URL="http://127.0.0.1:$port" \
|
||||
LITELLM_MODE=PRODUCTION LITELLM_LOCAL_MODEL_COST_MAP=True STORE_MODEL_IN_DB=True \
|
||||
AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
|
||||
.venv/bin/python -m integration._support.proxy --config tests/integration/proxy_config.yaml \
|
||||
|
|
@ -131,6 +142,19 @@ if [ "$suite" = providers ]; then
|
|||
--junitxml="$results/replay-controls.xml"
|
||||
fi
|
||||
|
||||
if [ "$suite" = browser ]; then
|
||||
export E2E_UI_BASE_URL="$INTEGRATION_PROXY_URL" E2E_UI_ARTIFACT_DIR="$PWD/$results"
|
||||
export INTEGRATION_PYTHON="$PWD/.venv/bin/python"
|
||||
timeout --signal=TERM --kill-after=20s 3m env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
||||
INTEGRATION_RUN_ID="$integration_identity" DATABASE_URL="$DATABASE_URL" \
|
||||
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" INTEGRATION_PYTHON="$INTEGRATION_PYTHON" \
|
||||
E2E_UI_BASE_URL="$E2E_UI_BASE_URL" E2E_UI_ARTIFACT_DIR="$E2E_UI_ARTIFACT_DIR" \
|
||||
LITELLM_MASTER_KEY="$LITELLM_MASTER_KEY" CI=true \
|
||||
node tests/e2e/ui/node_modules/@playwright/test/cli.js test --config tests/e2e/ui/integration.config.ts
|
||||
.venv/bin/python .circleci/scripts/verify_integration_browser.py "$results/browser-results.json"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
timeout --signal=TERM --kill-after=20s 11m env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
|
||||
INTEGRATION_RUN_ID="$integration_identity" \
|
||||
DATABASE_URL="$DATABASE_URL" REDIS_HOST="$REDIS_HOST" REDIS_PORT="$REDIS_PORT" \
|
||||
|
|
@ -138,5 +162,6 @@ timeout --signal=TERM --kill-after=20s 11m env -i PATH="$PATH" HOME="$HOME" PYTH
|
|||
INTEGRATION_UPSTREAM_URL="$INTEGRATION_UPSTREAM_URL" \
|
||||
INTEGRATION_MASTER_KEY="$INTEGRATION_MASTER_KEY" LITELLM_MODE=PRODUCTION \
|
||||
INTEGRATION_SEED="$INTEGRATION_SEED" \
|
||||
INTEGRATION_ORDER_SEED="$INTEGRATION_ORDER_SEED" \
|
||||
LITELLM_LOCAL_MODEL_COST_MAP=True AWS_EC2_METADATA_DISABLED=true DO_NOT_TRACK=1 \
|
||||
.venv/bin/python tests/integration/run.py "$suite" --results "$results"
|
||||
|
|
|
|||
60
.circleci/scripts/verify_integration_browser.py
Normal file
60
.circleci/scripts/verify_integration_browser.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
|
||||
class BrowserAttempt(TypedDict):
|
||||
status: ReadOnly[str]
|
||||
retry: ReadOnly[int]
|
||||
|
||||
|
||||
class BrowserTest(TypedDict):
|
||||
results: ReadOnly[list[BrowserAttempt]]
|
||||
|
||||
|
||||
class BrowserSpec(TypedDict):
|
||||
file: ReadOnly[str]
|
||||
title: ReadOnly[str]
|
||||
tests: ReadOnly[list[BrowserTest]]
|
||||
|
||||
|
||||
class BrowserSuite(TypedDict):
|
||||
specs: NotRequired[ReadOnly[list[BrowserSpec]]]
|
||||
suites: NotRequired[ReadOnly[list["BrowserSuite"]]]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
result: Final = json.loads(Path(sys.argv[1]).read_text())
|
||||
assert not result.get("errors"), result.get("errors")
|
||||
expected: Final = json.loads(
|
||||
(Path(__file__).resolve().parents[2] / "tests/integration/contracts.json").read_text()
|
||||
)["browser"]
|
||||
assert expected and result["stats"]["expected"] == len(expected)
|
||||
assert all(result["stats"][name] == 0 for name in ("unexpected", "flaky", "skipped"))
|
||||
|
||||
def cases(suite: BrowserSuite) -> tuple[BrowserSpec, ...]:
|
||||
return tuple(suite.get("specs", ())) + tuple(spec for child in suite.get("suites", ()) for spec in cases(child))
|
||||
|
||||
suites: Final = TypeAdapter(list[BrowserSuite]).validate_python(result["suites"], strict=True)
|
||||
specs: Final = tuple(spec for suite in suites for spec in cases(suite))
|
||||
repository: Final = Path(__file__).resolve().parents[2]
|
||||
report_root: Final = Path(result["config"]["rootDir"])
|
||||
assert report_root.is_absolute(), "Playwright rootDir must be explicit"
|
||||
observed: Final = tuple(
|
||||
str((report_root / spec["file"]).resolve().relative_to(repository)) + "::" + spec["title"] for spec in specs
|
||||
)
|
||||
assert sorted(observed) == sorted(expected)
|
||||
for spec in specs:
|
||||
tests: Final = spec["tests"]
|
||||
assert len(tests) == 1 and len(tests[0]["results"]) == 1
|
||||
assert tests[0]["results"][0]["status"] == "passed" and tests[0]["results"][0]["retry"] == 0
|
||||
|
||||
sys.stdout.write("One canonical browser contract passed once without skips or retries\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
.github/scripts/assert_ci_coverage.py
vendored
39
.github/scripts/assert_ci_coverage.py
vendored
|
|
@ -505,6 +505,7 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
|
|||
return frozenset(), ()
|
||||
entries: Final = json.loads(manifest.read_text())
|
||||
paths: Final = frozenset(node.split("::", 1)[0] for node in entries["tests"])
|
||||
browser_paths: Final = frozenset(node.split("::", 1)[0] for node in entries.get("browser", {}))
|
||||
circle_path: Final = repo_root / ".circleci/config.yml"
|
||||
circle: Final = yaml.safe_load(circle_path.read_text()) if circle_path.exists() else {}
|
||||
steps: Final = circle.get("jobs", {}).get("integration_contracts", {}).get("steps", ())
|
||||
|
|
@ -523,7 +524,7 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
|
|||
.get("suite", (job["integration_contracts"].get("suite"),))
|
||||
if isinstance(suite, str)
|
||||
)
|
||||
required: Final = frozenset(
|
||||
required: Final = (frozenset({"browser"}) if browser_paths else frozenset()) | frozenset(
|
||||
group
|
||||
for group, folders in entries["groups"].items()
|
||||
if any(any(path.startswith(f"tests/integration/{folder}/") for folder in folders) for path in paths)
|
||||
|
|
@ -551,6 +552,40 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
|
|||
for path in paths
|
||||
if not (repo_root / path).is_file()
|
||||
)
|
||||
browser_commands: Final = tuple(
|
||||
scalar.value
|
||||
for path in (repo_root / ".github/workflows").glob("*.y*ml")
|
||||
for scalar in _scalars(yaml.safe_load(path.read_text()), path.name)
|
||||
if scalar.key in {"run", "command"}
|
||||
)
|
||||
browser_findings: Final = tuple(
|
||||
Finding(path, "browser integration contract is explicitly selected by GitHub Actions")
|
||||
for path in browser_paths
|
||||
if any(
|
||||
path in command
|
||||
or pathlib.Path(path).name in command
|
||||
or "integrationCritical" in command
|
||||
or "integration.config.ts" in command
|
||||
or ("run_integration.sh" in command and "browser" in command)
|
||||
for command in browser_commands
|
||||
)
|
||||
) + tuple(
|
||||
Finding(path, "canonical browser integration file is missing")
|
||||
for path in browser_paths
|
||||
if not (repo_root / path).is_file()
|
||||
)
|
||||
default_browser: Final = repo_root / "tests/e2e/ui/playwright.config.ts"
|
||||
exclusion_findings: Final = (
|
||||
(
|
||||
Finding(
|
||||
str(default_browser.relative_to(repo_root)),
|
||||
"default Playwright selection must exclude integrationCritical",
|
||||
),
|
||||
)
|
||||
if browser_paths
|
||||
and (not default_browser.exists() or "**/integrationCritical/**" not in default_browser.read_text())
|
||||
else ()
|
||||
)
|
||||
group_findings: Final = tuple(
|
||||
Finding(group, "canonical integration group is not scheduled by CircleCI")
|
||||
for group in sorted(required - scheduled)
|
||||
|
|
@ -559,7 +594,7 @@ def _integration_ownership(repo_root: pathlib.Path = REPO_ROOT) -> tuple[frozens
|
|||
return frozenset(), findings + (
|
||||
Finding(str(manifest.relative_to(repo_root)), "dedicated CircleCI runner is missing"),
|
||||
)
|
||||
return paths, findings + group_findings
|
||||
return paths | browser_paths, findings + group_findings + browser_findings + exclusion_findings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
|
|
|||
30
tests/e2e/ui/integration.config.ts
Normal file
30
tests/e2e/ui/integration.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
import * as path from "path";
|
||||
import { ARTIFACT_DIR, UI_BASE_URL } from "./constants";
|
||||
|
||||
if (process.env.GITHUB_ACTIONS === "true")
|
||||
throw new Error("Integration contracts are owned by CircleCI");
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/integrationCritical",
|
||||
testMatch: "*.spec.ts",
|
||||
fullyParallel: false,
|
||||
forbidOnly: true,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 10_000 },
|
||||
reporter: [
|
||||
["line"],
|
||||
["junit", { outputFile: path.join(ARTIFACT_DIR, "browser-junit.xml") }],
|
||||
["json", { outputFile: path.join(ARTIFACT_DIR, "browser-results.json") }],
|
||||
],
|
||||
outputDir: path.join(ARTIFACT_DIR, "browser-output"),
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: UI_BASE_URL,
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ import { ARTIFACT_DIR, UI_BASE_URL } from "./constants";
|
|||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["**/*.spec.ts", "**/*.setup.ts"],
|
||||
testIgnore: ["**/*.test.*"],
|
||||
testIgnore: ["**/*.test.*", "**/integrationCritical/**"],
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
|
|
|
|||
232
tests/e2e/ui/tests/integrationCritical/projectDetachment.spec.ts
Normal file
232
tests/e2e/ui/tests/integrationCritical/projectDetachment.spec.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, openKeyDetail } from "../../helpers/navigation";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
test("project creation and explicit detachment preserve saved scope and restore serving", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const master = process.env.LITELLM_MASTER_KEY ?? "sk-integration-master";
|
||||
const headers = { Authorization: `Bearer ${master}` };
|
||||
const prefix = `integration-browser-${randomUUID()}`;
|
||||
// rebind-ok: Register cleanup after each acquisition so partial setup always unwinds in reverse order.
|
||||
const resources: Array<() => Promise<void>> = [];
|
||||
const post = async (url: string, data: object) => {
|
||||
const response = await request.post(url, { headers, data });
|
||||
expect(response.ok(), `${url}: ${await response.text()}`).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
const remove = (url: string, data: object) => async () => {
|
||||
await post(url, data);
|
||||
};
|
||||
const saved = (key: string) =>
|
||||
JSON.parse(
|
||||
execFileSync(
|
||||
process.env.INTEGRATION_PYTHON ?? "python",
|
||||
[
|
||||
path.resolve(
|
||||
__dirname,
|
||||
"../../../../integration/_support/browser_state.py",
|
||||
),
|
||||
createHash("sha256").update(key).digest("hex"),
|
||||
],
|
||||
{ encoding: "utf8", timeout: 10_000, killSignal: "SIGKILL" },
|
||||
),
|
||||
);
|
||||
try {
|
||||
const previous = await request.get("/get/ui_settings", { headers });
|
||||
expect(previous.ok(), await previous.text()).toBe(true);
|
||||
const priorEnabled =
|
||||
(await previous.json()).values.enable_projects_ui ?? false;
|
||||
resources.push(async () => {
|
||||
const response = await request.patch("/update/ui_settings", {
|
||||
headers,
|
||||
data: { enable_projects_ui: priorEnabled },
|
||||
});
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
});
|
||||
const settings = await request.patch("/update/ui_settings", {
|
||||
headers,
|
||||
data: { enable_projects_ui: true },
|
||||
});
|
||||
expect(settings.ok(), await settings.text()).toBe(true);
|
||||
for (const alias of [prefix, `${prefix}-outside`]) {
|
||||
const model = await post("/model/new", {
|
||||
model_name: alias,
|
||||
litellm_params: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
api_key: "synthetic-provider-key",
|
||||
api_base: `${process.env.INTEGRATION_UPSTREAM_URL}/v1`,
|
||||
},
|
||||
model_info: {},
|
||||
});
|
||||
resources.push(remove("/model/delete", { id: model.model_info.id }));
|
||||
}
|
||||
const team = await post("/team/new", {
|
||||
team_alias: prefix,
|
||||
models: [prefix],
|
||||
});
|
||||
resources.push(remove("/team/delete", { team_ids: [team.team_id] }));
|
||||
const project = await post("/project/new", {
|
||||
project_alias: prefix,
|
||||
team_id: team.team_id,
|
||||
models: [prefix],
|
||||
});
|
||||
resources.push(async () => {
|
||||
const response = await request.delete("/project/delete", {
|
||||
headers,
|
||||
data: { project_ids: [project.project_id] },
|
||||
});
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
});
|
||||
resources.push(async () => {
|
||||
const listing = await request.get(
|
||||
`/key/list?key_alias=${encodeURIComponent(prefix)}&return_full_object=true`,
|
||||
{ headers },
|
||||
);
|
||||
expect(listing.ok(), await listing.text()).toBe(true);
|
||||
for (const key of (await listing.json()).keys.filter(
|
||||
(key: { key_alias: string }) => key.key_alias === prefix,
|
||||
))
|
||||
await post("/key/delete", { keys: [key.token] });
|
||||
});
|
||||
await page.goto("/ui/login");
|
||||
await page.getByPlaceholder("Enter your username").fill("admin");
|
||||
await page.getByPlaceholder("Enter your password").fill(master);
|
||||
await page.getByRole("button", { name: "Login", exact: true }).click();
|
||||
await expect(page).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname.startsWith("/ui") && !url.pathname.includes("login"),
|
||||
);
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await page.getByRole("button", { name: /Create New Key/i }).click();
|
||||
await page.getByLabel(/Key Name/).fill(prefix);
|
||||
await page.getByPlaceholder("Search or select a project").fill(prefix);
|
||||
await page.getByRole("option", { name: new RegExp(prefix) }).click();
|
||||
await page.getByRole("combobox", { name: "Select models" }).click();
|
||||
await page.getByRole("option", { name: prefix, exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
const creating = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === "/key/generate",
|
||||
);
|
||||
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
const created = await creating;
|
||||
expect(created.ok(), await created.text()).toBe(true);
|
||||
const createBody = created.request().postDataJSON();
|
||||
expect(createBody.project_id).toBe(project.project_id);
|
||||
expect(createBody.team_id).toBe(team.team_id);
|
||||
const key = (await created.json()).key as string;
|
||||
expect(saved(key)).toEqual([
|
||||
{
|
||||
project_id: project.project_id,
|
||||
team_id: team.team_id,
|
||||
models: [prefix],
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
page.getByText("Save your Key", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const chat = (model: string) =>
|
||||
request.post("/v1/chat/completions", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
data: {
|
||||
model,
|
||||
messages: [{ role: "user", content: "synthetic browser control" }],
|
||||
},
|
||||
});
|
||||
const first = await chat(prefix);
|
||||
expect(first.status(), await first.text()).toBe(200);
|
||||
expect((await first.json()).usage.total_tokens).toBe(40);
|
||||
await post("/project/update", {
|
||||
project_id: project.project_id,
|
||||
blocked: true,
|
||||
});
|
||||
const blocked = await chat(prefix);
|
||||
expect(blocked.status(), await blocked.text()).toBe(401);
|
||||
expect((await blocked.json()).error.type).toBe("auth_error");
|
||||
const searched = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return (
|
||||
response.request().method() === "GET" &&
|
||||
url.pathname === "/key/list" &&
|
||||
url.searchParams.get("search") === prefix
|
||||
);
|
||||
});
|
||||
await page.getByPlaceholder("Search by key alias or ID").fill(prefix);
|
||||
const searchResponse = await searched;
|
||||
expect(searchResponse.ok(), await searchResponse.text()).toBe(true);
|
||||
expect(
|
||||
(await searchResponse.json()).keys.map(
|
||||
(entry: { key_alias: string }) => entry.key_alias,
|
||||
),
|
||||
).toEqual([prefix]);
|
||||
await expect(
|
||||
page.getByText("Loading keys...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Refresh", exact: true }),
|
||||
).toBeEnabled();
|
||||
await openKeyDetail(page, prefix);
|
||||
await page.getByRole("tab", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: "Edit Settings" }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Detach from project", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Keep project", exact: true }),
|
||||
).toBeVisible();
|
||||
const update = await captureRequestBody(
|
||||
page,
|
||||
{ method: "POST", urlIncludes: "/key/update" },
|
||||
async () => {
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
},
|
||||
);
|
||||
expect(update.project_id).toBeNull();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Edit Settings" }),
|
||||
).toBeVisible();
|
||||
await page.reload();
|
||||
const info = await readBack<{
|
||||
info: { project_id: string | null; team_id: string; models: string[] };
|
||||
}>(page, `/key/info?key=${encodeURIComponent(key)}`);
|
||||
expect(info.info.project_id).toBeNull();
|
||||
expect(saved(key)).toEqual([
|
||||
{ project_id: null, team_id: team.team_id, models: [prefix] },
|
||||
]);
|
||||
await page.getByRole("tab", { name: "Settings" }).click();
|
||||
await page.getByRole("button", { name: "Edit Settings" }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Detach from project" }),
|
||||
).toHaveCount(0);
|
||||
const restored = await chat(prefix);
|
||||
expect(restored.status(), await restored.text()).toBe(200);
|
||||
expect((await restored.json()).usage.total_tokens).toBe(40);
|
||||
const outside = await chat(`${prefix}-outside`);
|
||||
expect(outside.status(), await outside.text()).toBe(403);
|
||||
expect((await outside.json()).error.type).toBe("key_model_access_denied");
|
||||
await post("/key/delete", { keys: [key] });
|
||||
expect(saved(key)).toEqual([]);
|
||||
} finally {
|
||||
const failures = await resources.reduceRight<Promise<readonly unknown[]>>(
|
||||
async (previous, cleanup) => {
|
||||
const errors = await previous;
|
||||
try {
|
||||
await cleanup();
|
||||
return errors;
|
||||
} catch (error) {
|
||||
return [...errors, error];
|
||||
}
|
||||
},
|
||||
Promise.resolve([]),
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
These tests exercise a running gateway, PostgreSQL and Redis with an owned local upstream. CircleCI owns this suite. Tests are grouped by behavior, with no automatic test retries or fallback to paid provider calls
|
||||
|
||||
Use `tests/integration/run.py management`, `accounting`, `database` or `providers` to run a selected group. Set `INTEGRATION_PROXY_URL`, `INTEGRATION_UPSTREAM_URL`, `INTEGRATION_MASTER_KEY` and `DATABASE_URL` to an isolated test deployment. The runner selects the new domain directories explicitly; the legacy OCI and sandbox selections remain separate
|
||||
Use `tests/integration/run.py management`, `accounting`, `database`, `providers` or `extensions` to run a selected group. Set `INTEGRATION_PROXY_URL`, `INTEGRATION_UPSTREAM_URL`, `INTEGRATION_MASTER_KEY` and `DATABASE_URL` to an isolated test deployment. The runner selects the new domain directories explicitly; the legacy OCI and sandbox selections remain separate
|
||||
|
||||
Management also requires `INTEGRATION_PEER_URL`, `REDIS_HOST` and `REDIS_PORT`. CircleCI starts two directly addressed proxy processes sharing only that job's stores. The test-only CLI wrapper supplies enterprise route entitlement, following the existing behavior suite's convention. It does not qualify license validation; run it with one worker and no reload
|
||||
|
||||
The generated lifecycle models use 20 examples, eight steps, generation and shrinking, with isolated resources per example. HTTP operation caps include generation and shrinking and exempt cleanup. Local qualification defaults to seed 4106601; CircleCI derives its exploration seed from the checked-out revision. Use `--seed` to reproduce a run. Actual installed Hypothesis version, settings and seed are written beside the execution manifest
|
||||
The generated lifecycle models use 20 examples, eight steps, generation and shrinking, with isolated resources per example. HTTP operation caps include generation and shrinking and exempt cleanup. Local qualification defaults to seed 4106601 and canonical order; CircleCI derives exploration and ordering seeds from the checked-out revision and workflow ID. Use `--seed` and `--order-seed` to reproduce a run. Actual installed Hypothesis version, settings, seeds and collected order are written beside the execution manifest
|
||||
|
||||
Reuse the existing canned provider handlers through `_support/upstream.py`. It rejects internal request fields and exposes actual received requests for independent assertions. Register every created resource for cleanup immediately, keep expected values independent of production calculations, and assert readback plus the runtime effect of a change
|
||||
|
||||
|
|
@ -25,3 +25,7 @@ Accounting cases compare persisted input and output cost components against lite
|
|||
Provider contracts exercise actual TCP requests with synthetic credentials and local protocol peers. The S3 verifier uses independently implemented equations, a published known-answer vector, a fixed signing clock and deliberately invalid signed requests. Bedrock cases clear ambient AWS credential sources and check the literal model path, loaded role references, STS requests and bearer-only behavior
|
||||
|
||||
Streaming checks send real HTTP transfer chunks, including one-byte partitions, fragmented tools, incomplete transfers and a cancellation barrier. They assert meaningful text, tool arguments, final usage and persisted cost. The Redis recovery case owns a separate database and Redis process, uses the supported one-second circuit-breaker recovery setting, waits for the real subscriber and verifies response data in Redis after restart. CircleCI reuses its existing Redis image for that extra process; it never pulls an image during tests
|
||||
|
||||
The extensions shard reuses the existing MCP arithmetic functions with a real SDK server, and uses the built-in generic callback and guardrail transports. It checks actual tool calls after saved edits, discovery preservation, malformed/error responses, callback correlation and credential exclusion, guardrail rewriting and denial, retained OpenAI consumers, persisted toolsets and A2A wire versions
|
||||
|
||||
Browser contracts live in `tests/e2e/ui/tests/integrationCritical` and run only through `tests/e2e/ui/integration.config.ts`. The CircleCI browser shard builds the checked-out dashboard, starts the owned proxy with that build, and verifies one exact browser result without retries or skips. The default Playwright selection excludes this directory. The focused project flow asserts the submitted create and clear values, fresh SQL state and actual blocked/restored serving while preserving model restrictions
|
||||
|
|
|
|||
81
tests/integration/_support/asgi.py
Normal file
81
tests/integration/_support/asgi.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Final
|
||||
|
||||
import uvicorn
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
|
||||
@contextmanager
|
||||
def asgi_server(app: ASGIApp) -> Iterator[str]:
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port: Final = listener.getsockname()[1]
|
||||
server: Final = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
lifespan="on",
|
||||
log_level="warning",
|
||||
timeout_keep_alive=1,
|
||||
timeout_graceful_shutdown=5,
|
||||
)
|
||||
)
|
||||
errors: Final[queue.SimpleQueue[str]] = queue.SimpleQueue()
|
||||
loop_ready: Final[Future[asyncio.AbstractEventLoop]] = Future()
|
||||
|
||||
def serve() -> None:
|
||||
with asyncio.Runner() as runner:
|
||||
loop_ready.set_result(runner.get_loop())
|
||||
try:
|
||||
runner.run(server.serve(sockets=[listener]))
|
||||
except BaseException as error:
|
||||
errors.put(type(error).__name__ + ": " + str(error))
|
||||
if asyncio.all_tasks(runner.get_loop()):
|
||||
errors.put("Owned ASGI loop retained unfinished tasks")
|
||||
|
||||
worker: Final = threading.Thread(target=serve)
|
||||
|
||||
class Capture(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
if record.thread == worker.ident and record.levelno >= logging.ERROR:
|
||||
errors.put(record.getMessage())
|
||||
|
||||
handler: Final = Capture()
|
||||
logger: Final = logging.getLogger("uvicorn.error")
|
||||
logger.addHandler(handler)
|
||||
worker.start()
|
||||
try:
|
||||
deadline: Final = time.monotonic() + 8
|
||||
while not server.started:
|
||||
assert worker.is_alive() and time.monotonic() < deadline, "Owned ASGI peer failed readiness"
|
||||
time.sleep(0.01)
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
server.should_exit = True
|
||||
worker.join(timeout=8)
|
||||
forced: Final = worker.is_alive()
|
||||
if forced:
|
||||
server.force_exit = True
|
||||
loop: Final = loop_ready.result(timeout=1)
|
||||
|
||||
def cancel_owned() -> None:
|
||||
for task in asyncio.all_tasks(loop):
|
||||
task.cancel()
|
||||
|
||||
loop.call_soon_threadsafe(cancel_owned)
|
||||
worker.join(timeout=3)
|
||||
logger.removeHandler(handler)
|
||||
assert not worker.is_alive(), "Owned ASGI peer survived forced cleanup"
|
||||
assert not forced, "Owned ASGI peer required forced cleanup"
|
||||
assert not server.server_state.tasks, "Owned ASGI peer retained request tasks"
|
||||
assert not server.lifespan.error_occurred and not server.lifespan.shutdown_failed
|
||||
assert errors.empty(), tuple(errors.get_nowait() for _ in range(errors.qsize()))
|
||||
13
tests/integration/_support/browser_state.py
Normal file
13
tests/integration/_support/browser_state.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import json
|
||||
import sys
|
||||
|
||||
from integration._support.database import read_rows
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(
|
||||
json.dumps(
|
||||
read_rows(
|
||||
'SELECT project_id, team_id, models FROM "LiteLLM_VerificationToken" WHERE token=%s', (sys.argv[1],)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -27,6 +27,13 @@ def string_value(value: JsonValue) -> str:
|
|||
return value
|
||||
|
||||
|
||||
def delete_key_if_present(candidate: Gateway, key: str) -> None:
|
||||
digest: Final = sha256(key.encode()).hexdigest()
|
||||
if read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)):
|
||||
candidate.post("/key/delete", {"keys": [key]})
|
||||
assert read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)) == []
|
||||
|
||||
|
||||
def eventually(read: Callable[[], T], satisfied: Callable[[T], bool], seconds: float = 10) -> T:
|
||||
deadline: Final = time.monotonic() + seconds
|
||||
while True:
|
||||
|
|
|
|||
104
tests/integration/_support/mcp.py
Normal file
104
tests/integration/_support/mcp.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import json
|
||||
import queue
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from integration._support.asgi import asgi_server
|
||||
from integration._support.client import Gateway, Scenario
|
||||
from integration._support.database import read_rows
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp_tests.mcp_e2e_upstream_server import add, multiply
|
||||
from starlette.requests import Request
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class McpPeer:
|
||||
url: str
|
||||
calls: queue.Queue[dict[str, object]]
|
||||
|
||||
def drain(self) -> tuple[dict[str, object], ...]:
|
||||
return tuple(self.calls.get_nowait() for _ in range(self.calls.qsize()))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mcp_peer() -> Iterator[McpPeer]:
|
||||
service: Final = FastMCP(
|
||||
"integration-math",
|
||||
stateless_http=True,
|
||||
json_response=True,
|
||||
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||
)
|
||||
service.add_tool(add)
|
||||
service.add_tool(multiply)
|
||||
|
||||
@service.tool()
|
||||
def fail() -> str:
|
||||
raise ValueError("synthetic tool failure")
|
||||
|
||||
app: Final = service.streamable_http_app()
|
||||
observed: Final[queue.Queue[dict[str, object]]] = queue.Queue()
|
||||
|
||||
async def capture(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await app(scope, receive, send)
|
||||
return
|
||||
body: Final = await Request(scope, receive).body()
|
||||
assert len(body) <= 65536
|
||||
if body:
|
||||
observed.put({"body": json.loads(body), "headers": dict(scope["headers"])})
|
||||
message: Final[Message] = {"type": "http.request", "body": body, "more_body": False}
|
||||
pending: Final = iter((message,))
|
||||
|
||||
async def replay() -> Message:
|
||||
buffered: Final = next(pending, None)
|
||||
if buffered is not None:
|
||||
return buffered
|
||||
return await receive()
|
||||
|
||||
await app(scope, replay, send)
|
||||
|
||||
with asgi_server(capture) as url:
|
||||
yield McpPeer(url + "/mcp", observed)
|
||||
|
||||
|
||||
def register_mcp(scenario: Scenario, peer: McpPeer, alias: str, **fields: object) -> str:
|
||||
response: Final = scenario.gateway.request(
|
||||
"POST", "/v1/mcp/server", {"server_name": alias, "alias": alias, "url": peer.url, "transport": "http", **fields}
|
||||
)
|
||||
identity: Final = response.json()["server_id"]
|
||||
scenario.cleanups.callback(delete_mcp, scenario.gateway, identity)
|
||||
assert response.status_code == 201, response.text
|
||||
return identity
|
||||
|
||||
|
||||
def delete_mcp(gateway: Gateway, identity: str) -> None:
|
||||
response: Final = gateway.request("DELETE", f"/v1/mcp/server/{identity}")
|
||||
assert response.status_code == 202, response.text
|
||||
assert read_rows('SELECT server_id FROM "LiteLLM_MCPServerTable" WHERE server_id = %s', (identity,)) == []
|
||||
|
||||
|
||||
def tool_names(gateway: Gateway, key: str, identity: str) -> dict[str, str]:
|
||||
response: Final = gateway.client.get("/mcp-rest/tools/list", headers={"x-litellm-api-key": key})
|
||||
assert response.status_code == 200, response.text
|
||||
return {
|
||||
name: tool["name"]
|
||||
for tool in response.json()["tools"]
|
||||
if tool.get("mcp_info", {}).get("server_id") == identity
|
||||
for name in ("add", "multiply", "fail")
|
||||
if tool["name"].endswith(name)
|
||||
}
|
||||
|
||||
|
||||
def call_tool(
|
||||
gateway: Gateway, key: str, identity: str, name: str, arguments: dict[str, object]
|
||||
) -> httpx.Response:
|
||||
return gateway.client.post(
|
||||
"/mcp-rest/tools/call",
|
||||
headers={"x-litellm-api-key": key},
|
||||
json={"server_id": identity, "name": name, "arguments": arguments},
|
||||
)
|
||||
117
tests/integration/compatibility/test_a2a_wire_versions.py
Normal file
117
tests/integration/compatibility/test_a2a_wire_versions.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import json
|
||||
import uuid
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
@pytest.mark.covers("other.compatibility.a2a.supported_versions_preserve_literal_envelopes")
|
||||
def test_a2a_versions_and_legacy_casing_preserve_real_wire_and_response(gateway: Gateway) -> None:
|
||||
for version, legacy in (("0.3", False), ("1.0", False), ("0.3", True)):
|
||||
marker: Final = "a2a" + uuid.uuid4().hex
|
||||
|
||||
def upstream(request: Request, marker: str = marker, legacy: bool = legacy) -> Reply:
|
||||
if request.method == "GET":
|
||||
assert request.target in ("/.well-known/agent-card.json", "/.well-known/agent.json")
|
||||
card: Final = {
|
||||
"protocolVersion": "0.3",
|
||||
"name": marker,
|
||||
"description": "Synthetic arithmetic peer",
|
||||
"version": "1.0.0",
|
||||
"url": wire.url + "/",
|
||||
"capabilities": {"streaming": False},
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
"skills": [],
|
||||
}
|
||||
if legacy:
|
||||
card["supportedInterfaces"] = [
|
||||
{"url": wire.url + "/", "protocolBinding": "jsonrpc", "protocolVersion": "1.0"}
|
||||
]
|
||||
return Reply(body=json.dumps(card).encode())
|
||||
assert request.method == "POST" and request.target == "/"
|
||||
body: Final = json.loads(request.body)
|
||||
assert body["jsonrpc"] == "2.0" and body["method"] == "message/send"
|
||||
message: Final = body["params"]["message"]
|
||||
assert message["role"] == "user" and message["messageId"] == marker + "-in"
|
||||
assert message["parts"] == [{"kind": "text", "text": "synthetic ping"}]
|
||||
assert "message_id" not in message
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": body["id"],
|
||||
"result": {
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"messageId": marker + "-out",
|
||||
"parts": [{"kind": "text", "text": "synthetic pong"}],
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
with wire_server(upstream) as wire, gateway.scenario() as scenario:
|
||||
card: Final = {
|
||||
"protocolVersion": version,
|
||||
"name": marker,
|
||||
"description": "Synthetic arithmetic peer",
|
||||
"version": "1.0.0",
|
||||
"url": wire.url + "/",
|
||||
"capabilities": {"streaming": False},
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
"skills": [],
|
||||
}
|
||||
created: Final = gateway.request("POST", "/v1/agents", {"agent_name": marker, "agent_card_params": card})
|
||||
identity: Final = created.json()["agent_id"]
|
||||
|
||||
def cleanup(identity: str = identity) -> None:
|
||||
deleted: Final = gateway.request("DELETE", f"/v1/agents/{identity}")
|
||||
assert deleted.status_code == 200, deleted.text
|
||||
assert read_rows('SELECT agent_id FROM "LiteLLM_AgentsTable" WHERE agent_id=%s', (identity,)) == []
|
||||
|
||||
scenario.cleanups.callback(cleanup)
|
||||
assert created.status_code == 200, created.text
|
||||
assert gateway.get(f"/v1/agents/{identity}")["agent_card_params"]["protocolVersion"] == version
|
||||
discovered: Final = gateway.request("GET", f"/a2a/{identity}/.well-known/agent-card.json")
|
||||
assert discovered.status_code == 200, discovered.text
|
||||
parameters: Final = {
|
||||
"message": {
|
||||
"role": "ROLE_USER" if version == "1.0" else "user",
|
||||
"messageId": marker + "-in",
|
||||
"parts": [{"text": "synthetic ping"}]
|
||||
if version == "1.0"
|
||||
else [{"kind": "text", "text": "synthetic ping"}],
|
||||
}
|
||||
}
|
||||
response: Final = gateway.client.post(
|
||||
f"/a2a/{identity}",
|
||||
headers={"Authorization": f"Bearer {gateway.key}", "a2a-version": version},
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": marker,
|
||||
"method": "SendMessage" if version == "1.0" else "message/send",
|
||||
"params": parameters,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body: Final = response.json()
|
||||
assert body["jsonrpc"] == "2.0" and body["id"] == marker and "error" not in body
|
||||
result: Final = body["result"]
|
||||
message: Final = result["message"] if version == "1.0" else result
|
||||
assert message["messageId"] == marker + "-out"
|
||||
assert message["role"] == ("ROLE_AGENT" if version == "1.0" else "agent")
|
||||
assert message["parts"][0]["text"] == "synthetic pong"
|
||||
assert (
|
||||
("kind" not in result and "message" in result)
|
||||
if version == "1.0"
|
||||
else (result["kind"] == "message" and "message" not in result)
|
||||
)
|
||||
actual: Final = wire.drain()
|
||||
assert len(tuple(item for item in actual if item.method == "POST")) == 1
|
||||
assert any(item.method == "GET" for item in actual)
|
||||
109
tests/integration/compatibility/test_openai_consumer.py
Normal file
109
tests/integration/compatibility/test_openai_consumer.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import json
|
||||
import uuid
|
||||
from importlib.metadata import version
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
@pytest.mark.covers("other.compatibility.openai.retained_client_parses_tools_and_usage")
|
||||
async def test_retained_openai_clients_parse_real_proxy_tool_and_usage_responses(gateway: Gateway) -> None:
|
||||
assert version("openai") == "2.33.0", (
|
||||
"Retain this consumer version independently before upgrading the candidate lock"
|
||||
)
|
||||
|
||||
def provider(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/v1/chat/completions"
|
||||
body: Final = json.loads(request.body)
|
||||
tools: Final = body.get("tools")
|
||||
if tools:
|
||||
assert tools[0]["function"]["name"] == "add"
|
||||
message: Final = (
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "synthetic-call",
|
||||
"type": "function",
|
||||
"function": {"name": "add", "arguments": '{"a":3,"b":5}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
if tools
|
||||
else {"role": "assistant", "content": "Synthetic answer: 8"}
|
||||
)
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": "chatcmpl-" + uuid.uuid4().hex,
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "message": message, "finish_reason": "tool_calls" if tools else "stop"}],
|
||||
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
with wire_server(provider) as wire, gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(api_base=wire.url + "/v1")
|
||||
key: Final = scenario.key(models=[model])
|
||||
parameters: Final = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "synthetic tool request"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "add",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["a", "b"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"extra_body": {"cache": {"no-cache": True}},
|
||||
}
|
||||
plain: Final = {name: value for name, value in parameters.items() if name != "tools"}
|
||||
with OpenAI(
|
||||
api_key=key,
|
||||
base_url=str(gateway.client.base_url).rstrip("/") + "/v1",
|
||||
max_retries=0,
|
||||
http_client=httpx.Client(timeout=10, trust_env=False),
|
||||
) as sync:
|
||||
first: Final = sync.chat.completions.create(**parameters)
|
||||
first_text: Final = sync.chat.completions.create(**plain)
|
||||
async with AsyncOpenAI(
|
||||
api_key=key,
|
||||
base_url=str(gateway.client.base_url).rstrip("/") + "/v1",
|
||||
max_retries=0,
|
||||
http_client=httpx.AsyncClient(timeout=10, trust_env=False),
|
||||
) as asynchronous:
|
||||
second: Final = await asynchronous.chat.completions.create(**parameters)
|
||||
second_text: Final = await asynchronous.chat.completions.create(**plain)
|
||||
assert len({response.id for response in (first, second, first_text, second_text)}) == 4
|
||||
for response in (first, second, first_text, second_text):
|
||||
assert response.object == "chat.completion"
|
||||
assert (
|
||||
response.usage.prompt_tokens == 11
|
||||
and response.usage.completion_tokens == 4
|
||||
and response.usage.total_tokens == 15
|
||||
)
|
||||
for response in (first, second):
|
||||
assert response.choices[0].finish_reason == "tool_calls"
|
||||
call: Final = response.choices[0].message.tool_calls[0]
|
||||
assert call.id == "synthetic-call" and call.function.name == "add"
|
||||
assert json.loads(call.function.arguments) == {"a": 3, "b": 5}
|
||||
for response in (first_text, second_text):
|
||||
assert response.choices[0].finish_reason == "stop"
|
||||
assert response.choices[0].message.content == "Synthetic answer: 8"
|
||||
assert not response.choices[0].message.tool_calls
|
||||
assert len(wire.drain()) == 4
|
||||
50
tests/integration/compatibility/test_persisted_toolsets.py
Normal file
50
tests/integration/compatibility/test_persisted_toolsets.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.mcp import call_tool, mcp_peer, register_mcp, tool_names
|
||||
from integration._support.process import owned_proxy
|
||||
|
||||
|
||||
@pytest.mark.covers("other.compatibility.mcp.persisted_tool_names_survive_candidate_startup")
|
||||
def test_existing_toolset_format_loads_before_start_and_keeps_sibling_denied(gateway: Gateway, tmp_path: Path) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
identity: Final = register_mcp(scenario, peer, "integration" + uuid.uuid4().hex)
|
||||
toolset: Final = str(uuid.uuid4())
|
||||
|
||||
def cleanup() -> None:
|
||||
response: Final = gateway.request("DELETE", f"/v1/mcp/toolset/{toolset}")
|
||||
assert response.status_code == 202, response.text
|
||||
assert read_rows('SELECT toolset_id FROM "LiteLLM_MCPToolsetTable" WHERE toolset_id=%s', (toolset,)) == []
|
||||
|
||||
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
|
||||
connection.execute(
|
||||
'INSERT INTO "LiteLLM_MCPToolsetTable" (toolset_id, toolset_name, tools, updated_at) '
|
||||
'VALUES (%s,%s,%s::jsonb,NOW())',
|
||||
(toolset, "integration" + uuid.uuid4().hex, json.dumps([{"server_id": identity, "tool_name": "add"}])),
|
||||
)
|
||||
scenario.cleanups.callback(cleanup)
|
||||
key: Final = scenario.key(object_permission={"mcp_toolsets": [toolset]})
|
||||
control: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
with owned_proxy(gateway, tmp_path, {}) as candidate:
|
||||
full: Final = tool_names(candidate, control, identity)
|
||||
names: Final = tool_names(candidate, key, identity)
|
||||
assert set(names) == {"add"} and set(full) == {"add", "multiply", "fail"}
|
||||
result: Final = call_tool(candidate, key, identity, names["add"], {"a": 3, "b": 5})
|
||||
assert result.status_code == 200 and result.json()["isError"] is False, result.text
|
||||
assert result.json()["content"][0]["text"] == "8"
|
||||
peer.drain()
|
||||
denied: Final = call_tool(candidate, key, identity, full["multiply"], {"a": 3, "b": 5})
|
||||
assert denied.status_code == 403, denied.text
|
||||
assert "access" in denied.text.lower()
|
||||
assert not tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call")
|
||||
result: Final = call_tool(candidate, control, identity, full["multiply"], {"a": 3, "b": 5})
|
||||
assert result.status_code == 200 and result.json()["isError"] is False, result.text
|
||||
assert result.json()["content"][0]["text"] == "15"
|
||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
from importlib.metadata import version
|
||||
from collections.abc import Generator, Iterator
|
||||
from pathlib import Path
|
||||
|
|
@ -19,6 +20,10 @@ COLLECTED: Final = pytest.StashKey[tuple[str, ...]]()
|
|||
REPORTS: Final = pytest.StashKey[list[pytest.TestReport]]()
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
parser.addoption("--integration-order-seed", type=int, default=0)
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
config.addinivalue_line("markers", "integration: owned real-service integration contracts")
|
||||
config.addinivalue_line("markers", "covers(*ids): independently asserted behavior contracts")
|
||||
|
|
@ -26,6 +31,10 @@ def pytest_configure(config: pytest.Config) -> None:
|
|||
|
||||
|
||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||
order_seed: Final = config.getoption("integration_order_seed")
|
||||
if order_seed:
|
||||
# rebind-ok: pytest requires this hook to reorder its shared collection list in place.
|
||||
items.sort(key=lambda item: hashlib.sha256(f"{order_seed}:{item.nodeid}".encode()).digest())
|
||||
manifest: Final = contracts()
|
||||
root: Final = Path(__file__).parent
|
||||
owned: Final = tuple(
|
||||
|
|
@ -74,6 +83,7 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|||
"collected": collected, "passed": passed, "complete": complete, "exitstatus": exitstatus,
|
||||
"hypothesis_version": version("hypothesis"),
|
||||
"hypothesis_seed": session.config.getoption("hypothesis_seed"),
|
||||
"order_seed": session.config.getoption("integration_order_seed"),
|
||||
"generation": {
|
||||
"max_examples": LIFECYCLE_SETTINGS.max_examples,
|
||||
"stateful_step_count": LIFECYCLE_SETTINGS.stateful_step_count,
|
||||
|
|
|
|||
|
|
@ -150,6 +150,48 @@
|
|||
"tests/integration/providers/test_anthropic_wire.py::test_anthropic_tool_history_and_cache_tokens_keep_wire_and_accounting_contracts": [
|
||||
"other.provider_wire.anthropic.tool_history_system_cache_and_internal_fields",
|
||||
"quota_management.spend_tracking.cache_tokens.disjoint_classes_use_explicit_rates"
|
||||
],
|
||||
"tests/integration/mcp/test_mcp_lifecycle.py::test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit": [
|
||||
"mcp.call_tool.saved_headers.reach_actual_transport"
|
||||
],
|
||||
"tests/integration/mcp/test_mcp_lifecycle.py::test_tool_error_remains_error_and_healthy_sibling_returns_value": [
|
||||
"mcp.call_tool.errors.tool_failure_is_not_success"
|
||||
],
|
||||
"tests/integration/mcp/test_mcp_lifecycle.py::test_generated_mcp_edits_preserve_actual_headers_and_tool_results": [
|
||||
"other.mcp.lifecycle.generated_save_reload_preserves_effective_headers"
|
||||
],
|
||||
"tests/integration/observability/test_callback_delivery.py::test_concurrent_success_and_failure_join_callbacks_and_rows_without_credentials": [
|
||||
"other.observability.callbacks.credentials_stay_out_of_event_bodies",
|
||||
"other.observability.callbacks.concurrent_results_join_complete_events_and_rows"
|
||||
],
|
||||
"tests/integration/observability/test_guardrail_effects.py::test_guardrail_rewrites_system_and_user_in_actual_anthropic_request": [
|
||||
"other.observability.guardrails.rewrite_reaches_correct_anthropic_positions"
|
||||
],
|
||||
"tests/integration/compatibility/test_a2a_wire_versions.py::test_a2a_versions_and_legacy_casing_preserve_real_wire_and_response": [
|
||||
"other.compatibility.a2a.supported_versions_preserve_literal_envelopes"
|
||||
],
|
||||
"tests/integration/compatibility/test_persisted_toolsets.py::test_existing_toolset_format_loads_before_start_and_keeps_sibling_denied": [
|
||||
"other.compatibility.mcp.persisted_tool_names_survive_candidate_startup"
|
||||
],
|
||||
"tests/integration/mcp/test_oauth_configuration.py::test_partial_discovery_and_unrelated_edit_keep_actual_authorization_destination": [
|
||||
"other.mcp.oauth.discovery_cannot_erase_configured_authorization_endpoint"
|
||||
],
|
||||
"tests/integration/observability/test_guardrail_effects.py::test_guardrail_denial_prevents_provider_and_preserves_allowed_control": [
|
||||
"other.observability.guardrails.denial_prevents_provider_with_allowed_control"
|
||||
],
|
||||
"tests/integration/mcp/test_mcp_protocol_errors.py::test_jsonrpc_error_and_malformed_tool_result_remain_errors": [
|
||||
"other.mcp.errors.protocol_and_malformed_results_cannot_be_empty_success"
|
||||
],
|
||||
"tests/integration/compatibility/test_openai_consumer.py::test_retained_openai_clients_parse_real_proxy_tool_and_usage_responses": [
|
||||
"other.compatibility.openai.retained_client_parses_tools_and_usage"
|
||||
],
|
||||
"tests/integration/spend/test_filtered_ledger.py::test_rotated_keys_users_and_model_groups_preserve_success_failure_cache_ledger": [
|
||||
"quota_management.spend_tracking.filtered_ledger_preserves_owner_identity_and_totals"
|
||||
]
|
||||
},
|
||||
"browser": {
|
||||
"tests/e2e/ui/tests/integrationCritical/projectDetachment.spec.ts::project creation and explicit detachment preserve saved scope and restore serving": [
|
||||
"mgmt.key.ui.project_create_clear_preserves_serving_scope"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,18 +10,11 @@ import psycopg
|
|||
import pytest
|
||||
from psycopg import sql
|
||||
|
||||
from integration._support.client import Gateway, eventually, string_value
|
||||
from integration._support.client import Gateway, delete_key_if_present, eventually, string_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy
|
||||
|
||||
|
||||
def delete_if_present(candidate: Gateway, key: str) -> None:
|
||||
digest: Final = sha256(key.encode()).hexdigest()
|
||||
if read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)):
|
||||
candidate.post("/key/delete", {"keys": [key]})
|
||||
assert read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)) == []
|
||||
|
||||
|
||||
@pytest.mark.covers("other.database.regeneration.writer_updates_dependent_grants")
|
||||
def test_key_regeneration_uses_writer_with_a_real_readonly_reader(gateway: Gateway, tmp_path: Path) -> None:
|
||||
role: Final = f"integration_reader_{uuid.uuid4().hex}"
|
||||
|
|
@ -53,8 +46,8 @@ def test_key_regeneration_uses_writer_with_a_real_readonly_reader(gateway: Gatew
|
|||
outside: Final = scenario.model()
|
||||
old: Final = string_value(candidate.post("/key/generate", {"models": [outside]})["key"])
|
||||
new: Final = f"sk-integration-{uuid.uuid4().hex}"
|
||||
scenario.cleanups.callback(delete_if_present, gateway, old)
|
||||
scenario.cleanups.callback(delete_if_present, gateway, new)
|
||||
scenario.cleanups.callback(delete_key_if_present, gateway, old)
|
||||
scenario.cleanups.callback(delete_key_if_present, gateway, new)
|
||||
old_hash: Final = sha256(old.encode()).hexdigest()
|
||||
before: Final = candidate.request(
|
||||
"POST",
|
||||
|
|
|
|||
123
tests/integration/mcp/test_mcp_lifecycle.py
Normal file
123
tests/integration/mcp/test_mcp_lifecycle.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import uuid
|
||||
from contextlib import ExitStack
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from hypothesis import strategies as st
|
||||
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
|
||||
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
|
||||
from integration._support.mcp import call_tool, mcp_peer, register_mcp, tool_names
|
||||
|
||||
|
||||
@pytest.mark.covers("mcp.call_tool.saved_headers.reach_actual_transport")
|
||||
def test_saved_headers_reach_real_mcp_tool_and_survive_unrelated_edit(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
alias: Final = "integration" + uuid.uuid4().hex
|
||||
identity: Final = register_mcp(
|
||||
scenario, peer, alias, static_headers={"X-Integration-Saved": "synthetic-header-value"}
|
||||
)
|
||||
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
for generation in range(2):
|
||||
names: Final = tool_names(gateway, key, identity)
|
||||
assert set(names) == {"add", "multiply", "fail"}
|
||||
peer.drain()
|
||||
response: Final = call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["isError"] is False
|
||||
assert len(response.json()["content"]) == 1
|
||||
assert response.json()["content"][0]["type"] == "text"
|
||||
assert response.json()["content"][0]["text"] == "8"
|
||||
calls: Final = tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call")
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["headers"][b"x-integration-saved"] == b"synthetic-header-value"
|
||||
assert calls[0]["body"]["params"]["name"] == "add"
|
||||
assert calls[0]["body"]["params"]["arguments"] == {"a": 3, "b": 5}
|
||||
if generation == 0:
|
||||
updated: Final = gateway.request(
|
||||
"PUT", "/v1/mcp/server", {"server_id": identity, "server_name": alias + "renamed"}
|
||||
)
|
||||
assert updated.status_code == 202, updated.text
|
||||
rows: Final = read_rows('SELECT server_name FROM "LiteLLM_MCPServerTable" WHERE server_id = %s', (identity,))
|
||||
assert rows == [{"server_name": alias + "renamed"}]
|
||||
|
||||
|
||||
@pytest.mark.covers("mcp.call_tool.errors.tool_failure_is_not_success")
|
||||
def test_tool_error_remains_error_and_healthy_sibling_returns_value(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, gateway.scenario() as scenario:
|
||||
identity: Final = register_mcp(scenario, peer, "integration" + uuid.uuid4().hex)
|
||||
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
names: Final = tool_names(gateway, key, identity)
|
||||
failure: Final = call_tool(gateway, key, identity, names["fail"], {})
|
||||
assert failure.status_code == 200, failure.text
|
||||
assert failure.json()["isError"] is True
|
||||
assert "synthetic tool failure" in failure.json()["content"][0]["text"]
|
||||
healthy: Final = call_tool(gateway, key, identity, names["multiply"], {"a": 3, "b": 5})
|
||||
assert healthy.status_code == 200, healthy.text
|
||||
assert healthy.json()["isError"] is False
|
||||
assert healthy.json()["content"][0]["text"] == "15"
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
@pytest.mark.covers("other.mcp.lifecycle.generated_save_reload_preserves_effective_headers")
|
||||
def test_generated_mcp_edits_preserve_actual_headers_and_tool_results(gateway: Gateway) -> None:
|
||||
with mcp_peer() as peer, bounded_http_requests((gateway,), limit=1500) as budget:
|
||||
|
||||
class Servers(RuleBasedStateMachine):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.resources = ExitStack()
|
||||
self.marker = "first"
|
||||
self.name = "integration" + uuid.uuid4().hex
|
||||
try:
|
||||
scenario = self.resources.enter_context(gateway.scenario())
|
||||
self.identity = register_mcp(
|
||||
scenario, peer, self.name, static_headers={"X-Integration-Saved": self.marker}
|
||||
)
|
||||
self.key = scenario.key(object_permission={"mcp_servers": [self.identity]})
|
||||
except BaseException:
|
||||
with budget.cleanup():
|
||||
self.resources.close()
|
||||
raise
|
||||
|
||||
@rule(value=st.sampled_from(("first", "second", "third")))
|
||||
def header(self, value: str) -> None:
|
||||
response: Final = gateway.request(
|
||||
"PUT",
|
||||
"/v1/mcp/server",
|
||||
{"server_id": self.identity, "static_headers": {"X-Integration-Saved": value}},
|
||||
)
|
||||
assert response.status_code == 202, response.text
|
||||
self.marker = value
|
||||
|
||||
@rule(value=st.sampled_from(("original", "renamed")))
|
||||
def rename(self, value: str) -> None:
|
||||
response: Final = gateway.request(
|
||||
"PUT", "/v1/mcp/server", {"server_id": self.identity, "server_name": self.name + value}
|
||||
)
|
||||
assert response.status_code == 202, response.text
|
||||
|
||||
@invariant()
|
||||
def persisted_configuration_controls_actual_tools(self) -> None:
|
||||
names: Final = tool_names(gateway, self.key, self.identity)
|
||||
assert set(names) == {"add", "multiply", "fail"}
|
||||
peer.drain()
|
||||
result: Final = call_tool(gateway, self.key, self.identity, names["add"], {"a": 3, "b": 5})
|
||||
assert result.status_code == 200 and result.json()["isError"] is False, result.text
|
||||
assert result.json()["content"][0]["text"] == "8"
|
||||
calls: Final = tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call")
|
||||
assert len(calls) == 1 and calls[0]["headers"][b"x-integration-saved"] == self.marker.encode()
|
||||
assert (
|
||||
len(
|
||||
read_rows('SELECT server_id FROM "LiteLLM_MCPServerTable" WHERE server_id=%s', (self.identity,))
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
with budget.cleanup():
|
||||
self.resources.close()
|
||||
|
||||
run_state_machine_as_test(Servers, settings=LIFECYCLE_SETTINGS)
|
||||
86
tests/integration/mcp/test_mcp_protocol_errors.py
Normal file
86
tests/integration/mcp/test_mcp_protocol_errors.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import json
|
||||
import queue
|
||||
import uuid
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.mcp import McpPeer, call_tool, register_mcp, tool_names
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
@pytest.mark.covers("other.mcp.errors.protocol_and_malformed_results_cannot_be_empty_success")
|
||||
def test_jsonrpc_error_and_malformed_tool_result_remain_errors(gateway: Gateway) -> None:
|
||||
def provider(request: Request) -> Reply:
|
||||
if request.method != "POST":
|
||||
return Reply(status=405)
|
||||
body: Final = json.loads(request.body)
|
||||
method: Final = body["method"]
|
||||
if "id" not in body:
|
||||
return Reply(status=202)
|
||||
base: Final = {"jsonrpc": "2.0", "id": body["id"]}
|
||||
if method == "initialize":
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
**base,
|
||||
"result": {
|
||||
"protocolVersion": body["params"]["protocolVersion"],
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "synthetic-protocol-peer", "version": "1"},
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
if method == "tools/list":
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
**base,
|
||||
"result": {
|
||||
"tools": [
|
||||
{"name": name, "inputSchema": {"type": "object"}}
|
||||
for name in ("add", "multiply", "fail")
|
||||
]
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
assert method == "tools/call"
|
||||
name: Final = body["params"]["name"]
|
||||
if name == "fail":
|
||||
return Reply(
|
||||
body=json.dumps({**base, "error": {"code": -32042, "message": "synthetic JSON-RPC error"}}).encode()
|
||||
)
|
||||
result: Final = (
|
||||
{"content": "synthetic malformed content"}
|
||||
if name == "multiply"
|
||||
else {"content": [{"type": "text", "text": "8"}], "isError": False}
|
||||
)
|
||||
return Reply(body=json.dumps({**base, "result": result}).encode())
|
||||
|
||||
with wire_server(provider) as wire, gateway.scenario() as scenario:
|
||||
identity: Final = register_mcp(
|
||||
scenario, McpPeer(wire.url + "/mcp", queue.Queue()), "integration" + uuid.uuid4().hex
|
||||
)
|
||||
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
||||
names: Final = tool_names(gateway, key, identity)
|
||||
for name, expected in (("fail", "synthetic JSON-RPC error"), ("multiply", "validation")):
|
||||
wire.drain()
|
||||
response: Final = call_tool(gateway, key, identity, names[name], {})
|
||||
assert response.status_code == 200 and response.json()["isError"] is True, response.text
|
||||
assert expected.lower() in response.json()["content"][0]["text"].lower(), response.text
|
||||
assert (
|
||||
len(
|
||||
tuple(
|
||||
item
|
||||
for item in wire.drain()
|
||||
if item.method == "POST" and json.loads(item.body).get("method") == "tools/call"
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
control: Final = call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5})
|
||||
assert control.status_code == 200 and control.json()["isError"] is False, control.text
|
||||
assert control.json()["content"][0]["text"] == "8"
|
||||
104
tests/integration/mcp/test_oauth_configuration.py
Normal file
104
tests/integration/mcp/test_oauth_configuration.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import json
|
||||
import queue
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from typing import Final
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.mcp import McpPeer, register_mcp
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
@pytest.mark.covers("other.mcp.oauth.discovery_cannot_erase_configured_authorization_endpoint")
|
||||
def test_partial_discovery_and_unrelated_edit_keep_actual_authorization_destination(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
def discovery(request: Request) -> Reply:
|
||||
if request.target.startswith("/configured-authorize"):
|
||||
return Reply(body=b'{"synthetic_authorization_endpoint":true}')
|
||||
if request.target == "/mcp":
|
||||
return Reply(body=b'{"synthetic_resource":true}')
|
||||
if request.target.startswith("/.well-known/oauth-protected-resource"):
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"resource": wire.url + "/mcp",
|
||||
"authorization_servers": [wire.url],
|
||||
"scopes_supported": ["tools.read"],
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
if request.method == "GET":
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"issuer": wire.url,
|
||||
"token_endpoint": wire.url + "/discovered-token",
|
||||
"scopes_supported": ["tools.read"],
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
return Reply(status=401, body=b'{"error":"synthetic OAuth requirement"}')
|
||||
|
||||
with (
|
||||
wire_server(discovery) as wire,
|
||||
owned_proxy(gateway, tmp_path, {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "true"}) as candidate,
|
||||
candidate.scenario() as scenario,
|
||||
):
|
||||
gateway = candidate
|
||||
alias: Final = "integration" + uuid.uuid4().hex
|
||||
endpoint: Final = wire.url + "/configured-authorize"
|
||||
identity: Final = register_mcp(
|
||||
scenario,
|
||||
McpPeer(wire.url + "/mcp", queue.Queue()),
|
||||
alias,
|
||||
auth_type="oauth2",
|
||||
authorization_url=endpoint,
|
||||
token_url=wire.url + "/configured-token",
|
||||
oauth2_flow="authorization_code",
|
||||
credentials={"client_id": "synthetic-oauth-client"},
|
||||
)
|
||||
discovered = []
|
||||
|
||||
def observed() -> tuple[Request, ...]:
|
||||
discovered.extend(wire.drain())
|
||||
return tuple(item for item in discovered if item.method == "GET" and ".well-known/" in item.target)
|
||||
|
||||
assert eventually(observed, bool, seconds=10)
|
||||
for generation in range(2):
|
||||
rows: Final = read_rows(
|
||||
'SELECT authorization_url FROM "LiteLLM_MCPServerTable" WHERE server_id=%s', (identity,)
|
||||
)
|
||||
assert rows == [{"authorization_url": endpoint}]
|
||||
response: Final = gateway.request(
|
||||
"GET",
|
||||
f"/v1/mcp/server/oauth/{identity}/authorize",
|
||||
params={
|
||||
"redirect_uri": "http://127.0.0.1:8765/callback",
|
||||
"state": "synthetic-state",
|
||||
"code_challenge": "A" * 43,
|
||||
"code_challenge_method": "S256",
|
||||
"response_type": "code",
|
||||
},
|
||||
)
|
||||
assert response.status_code in (302, 307), response.text
|
||||
location: Final = urlsplit(response.headers["location"])
|
||||
assert location.scheme + "://" + location.netloc + location.path == endpoint
|
||||
query: Final = parse_qs(location.query)
|
||||
assert query["client_id"] == ["synthetic-oauth-client"]
|
||||
assert query["scope"] == ["tools.read"], (
|
||||
"Discovery metadata must be applied before checking endpoint preservation"
|
||||
)
|
||||
assert query["code_challenge"] == ["A" * 43] and query["code_challenge_method"] == ["S256"]
|
||||
selected: Final = gateway.client.get(response.headers["location"])
|
||||
assert selected.status_code == 200 and selected.json() == {"synthetic_authorization_endpoint": True}
|
||||
if generation == 0:
|
||||
updated: Final = gateway.request(
|
||||
"PUT", "/v1/mcp/server", {"server_id": identity, "server_name": alias + "renamed"}
|
||||
)
|
||||
assert updated.status_code == 202, updated.text
|
||||
154
tests/integration/observability/test_callback_delivery.py
Normal file
154
tests/integration/observability/test_callback_delivery.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import json
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
@pytest.mark.covers(
|
||||
"other.observability.callbacks.credentials_stay_out_of_event_bodies",
|
||||
"other.observability.callbacks.concurrent_results_join_complete_events_and_rows",
|
||||
)
|
||||
def test_concurrent_success_and_failure_join_callbacks_and_rows_without_credentials(
|
||||
gateway: Gateway, tmp_path: Path
|
||||
) -> None:
|
||||
marker: Final = "callback" + uuid.uuid4().hex
|
||||
secret: Final = "synthetic-provider-secret-" + marker
|
||||
sink_secret: Final = "synthetic-sink-secret-" + marker
|
||||
|
||||
def upstream(request: Request) -> Reply:
|
||||
body: Final = json.loads(request.body)
|
||||
text: Final = body["messages"][0]["content"]
|
||||
assert request.headers["authorization"] == f"Bearer {secret}"
|
||||
if text.endswith("failure"):
|
||||
return Reply(
|
||||
status=400,
|
||||
body=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"code": "synthetic_failure",
|
||||
"message": "synthetic callback failure",
|
||||
}
|
||||
}
|
||||
).encode(),
|
||||
)
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": text,
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}
|
||||
],
|
||||
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
def sink(request: Request) -> Reply:
|
||||
assert request.headers["authorization"] == f"Bearer {sink_secret}"
|
||||
return Reply()
|
||||
|
||||
with wire_server(upstream) as provider, wire_server(sink) as endpoint:
|
||||
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
config["litellm_settings"].update({"callbacks": ["generic_api"], "DEFAULT_FLUSH_INTERVAL_SECONDS": 1})
|
||||
path: Final = tmp_path / "callbacks.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
with (
|
||||
owned_proxy(
|
||||
gateway,
|
||||
tmp_path,
|
||||
{
|
||||
"GENERIC_LOGGER_ENDPOINT": endpoint.url,
|
||||
"GENERIC_LOGGER_HEADERS": f"Authorization=Bearer {sink_secret}",
|
||||
},
|
||||
config=path,
|
||||
) as candidate,
|
||||
candidate.scenario() as scenario,
|
||||
):
|
||||
model: Final = scenario.model(
|
||||
api_base=provider.url + "/v1", api_key=secret, input_cost_per_token=0.001, output_cost_per_token=0.002
|
||||
)
|
||||
key: Final = scenario.key(models=[model])
|
||||
tags: Final = tuple(f"{marker}-{index}-{'failure' if index % 2 else 'success'}" for index in range(4))
|
||||
|
||||
def request(tag: str):
|
||||
return candidate.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": tag}],
|
||||
"metadata": {"tags": [tag]},
|
||||
"cache": {"no-cache": True},
|
||||
},
|
||||
key=key,
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
responses: Final = tuple(pool.map(request, tags))
|
||||
assert tuple(response.status_code for response in responses) == (200, 400, 200, 400)
|
||||
assert len(provider.drain()) == 4
|
||||
batches = []
|
||||
|
||||
def delivered() -> tuple[dict, ...]:
|
||||
batches.extend(endpoint.drain())
|
||||
return tuple(
|
||||
event
|
||||
for batch in batches
|
||||
for event in json.loads(batch.body)
|
||||
if any(tag in event.get("request_tags", []) for tag in tags)
|
||||
)
|
||||
|
||||
events: Final = eventually(delivered, lambda values: len(values) == 4, seconds=10)
|
||||
body: Final = b"".join(batch.body for batch in batches)
|
||||
for credential in (secret, sink_secret, key, candidate.key):
|
||||
assert credential.encode() not in body
|
||||
assert len({event["id"] for event in events}) == 4
|
||||
assert {tuple(tag for tag in event["request_tags"] if tag in tags) for event in events} == {
|
||||
(tag,) for tag in tags
|
||||
}
|
||||
for tag, response in zip(tags, responses, strict=True):
|
||||
event: Final = next(event for event in events if tag in event["request_tags"])
|
||||
assert event["litellm_call_id"] == response.headers["x-litellm-call-id"]
|
||||
assert event["status"] == ("failure" if tag.endswith("failure") else "success")
|
||||
if response.status_code == 200:
|
||||
assert response.json()["id"] == event["id"] == tag
|
||||
assert response.json()["choices"][0]["message"]["content"] == tag
|
||||
assert event["prompt_tokens"] == 11 and event["completion_tokens"] == 4
|
||||
assert event["response_cost"] == pytest.approx(0.019)
|
||||
else:
|
||||
assert event["response_cost"] == 0
|
||||
assert "synthetic callback failure" in json.dumps(event["error_information"])
|
||||
rows: Final = eventually(
|
||||
lambda identity=event["id"]: read_rows(
|
||||
'SELECT request_id, spend, prompt_tokens, completion_tokens, request_tags '
|
||||
'FROM "LiteLLM_SpendLogs" WHERE request_id=%s',
|
||||
(identity,),
|
||||
),
|
||||
lambda values: len(values) == 1,
|
||||
seconds=70,
|
||||
)
|
||||
saved_tags: Final = (
|
||||
json.loads(rows[0]["request_tags"])
|
||||
if isinstance(rows[0]["request_tags"], str)
|
||||
else rows[0]["request_tags"]
|
||||
)
|
||||
assert [value for value in saved_tags if value in tags] == [tag]
|
||||
assert float(rows[0]["spend"]) == pytest.approx(event["response_cost"])
|
||||
assert rows[0]["completion_tokens"] == event["completion_tokens"]
|
||||
if response.status_code == 200:
|
||||
assert rows[0]["prompt_tokens"] == event["prompt_tokens"]
|
||||
else:
|
||||
assert event["prompt_tokens"] == event["completion_tokens"] == rows[0]["completion_tokens"] == 0
|
||||
145
tests/integration/observability/test_guardrail_effects.py
Normal file
145
tests/integration/observability/test_guardrail_effects.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from integration._support.client import Gateway, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import owned_proxy
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
@pytest.mark.covers("other.observability.guardrails.rewrite_reaches_correct_anthropic_positions")
|
||||
def test_guardrail_rewrites_system_and_user_in_actual_anthropic_request(gateway: Gateway, tmp_path: Path) -> None:
|
||||
identity: Final = "guardrail" + uuid.uuid4().hex
|
||||
originals: Final = ["synthetic private system", "synthetic private user", "unchanged sibling"]
|
||||
replacements: Final = ["permitted system", "permitted user", "unchanged sibling"]
|
||||
|
||||
def guardrail(request: Request) -> Reply:
|
||||
assert request.target == "/beta/litellm_basic_guardrail_api"
|
||||
body: Final = json.loads(request.body)
|
||||
assert body["texts"] == originals
|
||||
return Reply(body=json.dumps({"action": "GUARDRAIL_INTERVENED", "texts": replacements}).encode())
|
||||
|
||||
def provider(request: Request) -> Reply:
|
||||
assert request.target == "/v1/messages"
|
||||
body: Final = json.loads(request.body)
|
||||
assert body["system"] == [{"type": "text", "text": replacements[0]}]
|
||||
assert body["messages"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": replacements[1]}, {"type": "text", "text": replacements[2]}],
|
||||
}
|
||||
]
|
||||
assert all(text.encode() not in request.body for text in originals[:2])
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": identity,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-sonnet-4-5-20250929",
|
||||
"content": [{"type": "text", "text": "permitted response"}],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 11, "output_tokens": 4},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
with wire_server(guardrail) as policy, wire_server(provider) as upstream:
|
||||
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
config["guardrails"] = [
|
||||
{
|
||||
"guardrail_name": identity,
|
||||
"litellm_params": {
|
||||
"guardrail": "generic_guardrail_api",
|
||||
"mode": "pre_call",
|
||||
"default_on": True,
|
||||
"api_base": policy.url,
|
||||
"api_key": "synthetic-guardrail-key",
|
||||
},
|
||||
}
|
||||
]
|
||||
path: Final = tmp_path / "rewrite.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario:
|
||||
model: Final = scenario.model(
|
||||
model="anthropic/claude-sonnet-4-5-20250929", api_base=upstream.url, api_key="synthetic-anthropic-key"
|
||||
)
|
||||
response: Final = candidate.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"max_tokens": 16,
|
||||
"messages": [
|
||||
{"role": "system", "content": originals[0]},
|
||||
{"role": "user", "content": [{"type": "text", "text": text} for text in originals[1:]]},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["choices"][0]["message"]["content"] == "permitted response"
|
||||
assert response.json()["choices"][0]["finish_reason"] == "stop"
|
||||
assert response.json()["usage"]["total_tokens"] == 15
|
||||
assert len(policy.drain()) == len(upstream.drain()) == 1
|
||||
|
||||
|
||||
@pytest.mark.covers("other.observability.guardrails.denial_prevents_provider_with_allowed_control")
|
||||
def test_guardrail_denial_prevents_provider_and_preserves_allowed_control(gateway: Gateway, tmp_path: Path) -> None:
|
||||
identity: Final = "guardrail" + uuid.uuid4().hex
|
||||
|
||||
def guardrail(request: Request) -> Reply:
|
||||
assert request.target == "/beta/litellm_basic_guardrail_api"
|
||||
body: Final = json.loads(request.body)
|
||||
assert body["texts"] in (["synthetic denied marker"], ["synthetic allowed marker"])
|
||||
result: Final = (
|
||||
{"action": "BLOCKED", "blocked_reason": "synthetic policy denial"}
|
||||
if body["texts"] == ["synthetic denied marker"]
|
||||
else {"action": "NONE"}
|
||||
)
|
||||
return Reply(body=json.dumps(result).encode())
|
||||
|
||||
with wire_server(guardrail) as policy:
|
||||
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||
config["guardrails"] = [
|
||||
{
|
||||
"guardrail_name": identity,
|
||||
"litellm_params": {
|
||||
"guardrail": "generic_guardrail_api",
|
||||
"mode": "pre_call",
|
||||
"default_on": True,
|
||||
"api_base": policy.url,
|
||||
"api_key": "synthetic-guardrail-key",
|
||||
},
|
||||
}
|
||||
]
|
||||
path: Final = tmp_path / "deny.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
with owned_proxy(gateway, tmp_path, {}, config=path) as candidate, candidate.scenario() as scenario:
|
||||
model: Final = scenario.model()
|
||||
key: Final = scenario.key(models=[model])
|
||||
import httpx
|
||||
|
||||
with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as observed:
|
||||
observed.get("/__observations")
|
||||
denied: Final = candidate.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": "synthetic denied marker"}]},
|
||||
key=key,
|
||||
)
|
||||
assert denied.status_code == 400 and "synthetic policy denial" in denied.text, denied.text
|
||||
assert observed.get("/__observations").json()["requests"] == []
|
||||
allowed: Final = candidate.chat(model, text="synthetic allowed marker", key=key)
|
||||
assert allowed["usage"]["total_tokens"] == 40
|
||||
assert (
|
||||
allowed["choices"][0]["message"]["content"]
|
||||
== "Hello! This is a mock response from the fake OpenAI endpoint."
|
||||
)
|
||||
assert len(observed.get("/__observations").json()["requests"]) == 1
|
||||
assert len(policy.drain()) == 2
|
||||
|
|
@ -107,7 +107,7 @@ def test_default_prices_survive_nullable_sibling_and_reload(gateway: Gateway) ->
|
|||
def test_loaded_router_preserves_cached_defaults_during_real_requests(gateway: Gateway, tmp_path: Path) -> None:
|
||||
from litellm import Router
|
||||
|
||||
aliases: Final = (f"pricing-{uuid.uuid4().hex}", f"pricing-{uuid.uuid4().hex}")
|
||||
aliases: Final = tuple(f"pricing-{uuid.uuid4().hex}" for _ in range(3))
|
||||
path: Final = tmp_path / "models.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump(
|
||||
|
|
@ -123,7 +123,13 @@ def test_loaded_router_preserves_cached_defaults_during_real_requests(gateway: G
|
|||
"model_info": {"id": alias, **pricing},
|
||||
}
|
||||
for alias, pricing in zip(
|
||||
aliases, ({}, {"input_cost_per_token": None, "output_cost_per_token": None}), strict=True
|
||||
aliases,
|
||||
(
|
||||
{},
|
||||
{"input_cost_per_token": None, "output_cost_per_token": None},
|
||||
{"input_cost_per_token": 0.0, "output_cost_per_token": 0.0},
|
||||
),
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
}
|
||||
|
|
@ -139,10 +145,12 @@ def test_loaded_router_preserves_cached_defaults_during_real_requests(gateway: G
|
|||
)
|
||||
assert result.usage.prompt_tokens == 20
|
||||
assert result.usage.completion_tokens == 20
|
||||
expected_cost: Final = 0.0 if alias == aliases[2] else 20 * 0.00000015 + 20 * 0.0000006
|
||||
assert result._hidden_params["response_cost"] == pytest.approx(expected_cost, rel=1e-6)
|
||||
deployment: Final = router.get_deployment(model_id=alias)
|
||||
assert deployment is not None
|
||||
info: Final = router.get_router_model_info(deployment=deployment, received_model_name=alias)
|
||||
assert info["input_cost_per_token"] == 0.00000015
|
||||
assert info["output_cost_per_token"] == 0.0000006
|
||||
assert info["input_cost_per_token"] == (0.0 if alias == aliases[2] else 0.00000015)
|
||||
assert info["output_cost_per_token"] == (0.0 if alias == aliases[2] else 0.0000006)
|
||||
finally:
|
||||
router.reset()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ def main() -> int:
|
|||
parser.add_argument("group", choices=tuple(GROUPS))
|
||||
parser.add_argument("--results", type=Path, default=Path("test-results/integration"))
|
||||
parser.add_argument("--seed", type=int, default=int(os.environ.get("INTEGRATION_SEED", "4106601")))
|
||||
parser.add_argument("--order-seed", type=int, default=int(os.environ.get("INTEGRATION_ORDER_SEED", "0")))
|
||||
options: Final = parser.parse_args()
|
||||
root: Final = Path(__file__).resolve().parents[2]
|
||||
selected: Final = tuple(
|
||||
|
|
@ -53,6 +54,7 @@ def main() -> int:
|
|||
"--timeout=90",
|
||||
"--durations=15",
|
||||
f"--hypothesis-seed={options.seed}",
|
||||
f"--integration-order-seed={options.order_seed}",
|
||||
f"--junitxml={output / 'junit.xml'}",
|
||||
],
|
||||
cwd=root,
|
||||
|
|
|
|||
|
|
@ -122,7 +122,18 @@ def test_repeated_hits_keep_response_identity_and_create_distinct_zero_cost_rows
|
|||
key: Final = scenario.key(models=[model])
|
||||
prompt: Final = f"repeated cache {uuid.uuid4().hex}"
|
||||
upstream.get("/__observations").raise_for_status()
|
||||
results: Final = tuple(gateway.chat(model, key=key, text=prompt) for _ in range(3))
|
||||
results: Final = tuple(
|
||||
gateway.post(
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"metadata": {"integration_marker": f"{prompt}-{index}"},
|
||||
},
|
||||
key=key,
|
||||
)
|
||||
for index in range(3)
|
||||
)
|
||||
assert len(upstream.get("/__observations").json()["requests"]) == 1
|
||||
assert len({result["id"] for result in results}) == 1
|
||||
for result in results:
|
||||
|
|
|
|||
165
tests/integration/spend/test_filtered_ledger.py
Normal file
165
tests/integration/spend/test_filtered_ledger.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha256
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway, delete_key_if_present, eventually
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.wire import Reply, Request, wire_server
|
||||
|
||||
|
||||
@pytest.mark.covers("quota_management.spend_tracking.filtered_ledger_preserves_owner_identity_and_totals")
|
||||
def test_rotated_keys_users_and_model_groups_preserve_success_failure_cache_ledger(gateway: Gateway) -> None:
|
||||
def provider(request: Request) -> Reply:
|
||||
assert request.method == "POST" and request.target == "/v1/chat/completions"
|
||||
body: Final = json.loads(request.body)
|
||||
if body["messages"][-1]["content"].endswith("reject"):
|
||||
return Reply(
|
||||
status=400,
|
||||
body=b'{"error":{"message":"synthetic ledger rejection","type":"invalid_request_error","code":"400"}}',
|
||||
)
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": "chatcmpl-" + uuid.uuid4().hex,
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "synthetic ledger answer"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 20, "completion_tokens": 20, "total_tokens": 40},
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
|
||||
with wire_server(provider) as wire, gateway.scenario() as scenario:
|
||||
owners: Final = (scenario.user(), scenario.user())
|
||||
models: Final = tuple(
|
||||
scenario.model(
|
||||
api_base=wire.url + "/v1", input_cost_per_token=0.001, output_cost_per_token=0.002, num_retries=0
|
||||
)
|
||||
for _ in owners
|
||||
)
|
||||
keys = []
|
||||
for owner, model in zip(owners, models, strict=True):
|
||||
created: Final = gateway.post("/key/generate", {"user_id": owner, "models": [model]})["key"]
|
||||
scenario.cleanups.callback(delete_key_if_present, gateway, created)
|
||||
keys.append(created)
|
||||
rotated: Final = "sk-" + uuid.uuid4().hex
|
||||
scenario.cleanups.callback(delete_key_if_present, gateway, rotated)
|
||||
changed: Final = gateway.post("/key/regenerate", {"key": keys[0], "new_key": rotated, "grace_period": "0s"})
|
||||
assert changed["key"] == rotated
|
||||
active: Final = (rotated, keys[1])
|
||||
digests: Final = tuple(sha256(key.encode()).hexdigest() for key in active)
|
||||
ledger: dict[str, tuple[str, str, str]] = {}
|
||||
for owner, model, key, digest in zip(owners, models, active, digests, strict=True):
|
||||
assert read_rows('SELECT user_id FROM "LiteLLM_VerificationToken" WHERE token=%s', (digest,)) == [
|
||||
{"user_id": owner}
|
||||
]
|
||||
prompt: Final = uuid.uuid4().hex
|
||||
replies = []
|
||||
for index in range(2):
|
||||
result: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"metadata": {"integration_marker": f"{prompt}-{index}"},
|
||||
},
|
||||
key=key,
|
||||
)
|
||||
assert result.status_code == 200, result.text
|
||||
body: Final = result.json()
|
||||
assert body["choices"][0]["message"]["content"] == "synthetic ledger answer"
|
||||
assert (
|
||||
body["usage"]["prompt_tokens"] == 20
|
||||
and body["usage"]["completion_tokens"] == 20
|
||||
and body["usage"]["total_tokens"] == 40
|
||||
)
|
||||
replies.append(body["id"])
|
||||
assert replies[0] == replies[1]
|
||||
rejected: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": prompt + "reject"}]},
|
||||
key=key,
|
||||
)
|
||||
assert rejected.status_code == 400 and "synthetic ledger rejection" in rejected.text
|
||||
ledger[digest] = (replies[0], rejected.headers["x-litellm-call-id"], model)
|
||||
observed: Final = wire.drain()
|
||||
assert len(observed) == 4
|
||||
assert sum(json.loads(item.body)["messages"][-1]["content"].endswith("reject") for item in observed) == 2
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id, api_key, "user", model_group, status, cache_hit, spend, '
|
||||
'prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE api_key=ANY(%s)',
|
||||
(list(digests),),
|
||||
),
|
||||
lambda values: len(values) == 6,
|
||||
seconds=70,
|
||||
)
|
||||
assert len({row["request_id"] for row in rows}) == 6
|
||||
assert sum(float(row["spend"]) for row in rows) == pytest.approx(0.12)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
window: Final = {
|
||||
"start_date": (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_date": (now + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"page_size": "100",
|
||||
}
|
||||
for owner, key, digest in zip(owners, active, digests, strict=True):
|
||||
identity, failure, model = ledger[digest]
|
||||
selected: Final = tuple(row for row in rows if row["api_key"] == digest)
|
||||
assert len(selected) == 3 and all(row["user"] == owner and row["model_group"] == model for row in selected)
|
||||
assert sum(row["status"] == "success" for row in selected) == 2
|
||||
assert sum(row["status"] == "failure" for row in selected) == 1
|
||||
assert sum(str(row["cache_hit"]).lower() == "true" for row in selected) == 1
|
||||
assert sorted(float(row["spend"]) for row in selected) == [0, 0, 0.06]
|
||||
for row in selected:
|
||||
hit: Final = str(row["cache_hit"]).lower() == "true"
|
||||
if row["request_id"] == identity:
|
||||
assert row["status"] == "success" and not hit and float(row["spend"]) == pytest.approx(0.06)
|
||||
elif row["request_id"] == failure:
|
||||
assert row["status"] == "failure" and not hit and float(row["spend"]) == 0
|
||||
assert row["completion_tokens"] == 0
|
||||
else:
|
||||
assert row["request_id"].startswith(identity + "_cache_hit")
|
||||
assert row["status"] == "success" and hit and float(row["spend"]) == 0
|
||||
if row["status"] == "success":
|
||||
assert row["prompt_tokens"] == 20 and row["completion_tokens"] == 20
|
||||
expected: Final = {row["request_id"] for row in selected}
|
||||
|
||||
def projection(row):
|
||||
return (
|
||||
row["request_id"],
|
||||
row["api_key"],
|
||||
row["user"],
|
||||
row["model_group"],
|
||||
row["status"],
|
||||
str(row["cache_hit"]).lower(),
|
||||
float(row["spend"]),
|
||||
row["prompt_tokens"],
|
||||
row["completion_tokens"],
|
||||
)
|
||||
|
||||
projected: Final = sorted(projection(row) for row in selected)
|
||||
for query in ({"api_key": digest}, {"user_id": owner}, {"model_group": model}):
|
||||
filtered: Final = gateway.get("/spend/logs/v2", params={**window, **query})
|
||||
assert filtered["total"] == 3 and filtered["total_is_capped"] is False
|
||||
assert len(filtered["data"]) == 3
|
||||
assert {row["request_id"] for row in filtered["data"]} == expected
|
||||
assert sorted(projection(row) for row in filtered["data"]) == projected
|
||||
for token in (key, digest):
|
||||
legacy: Final = gateway.request("GET", "/spend/logs", params={"api_key": token})
|
||||
assert legacy.status_code == 200, legacy.text
|
||||
assert len(legacy.json()) == 3
|
||||
assert {row["request_id"] for row in legacy.json()} == expected
|
||||
assert sorted(projection(row) for row in legacy.json()) == projected
|
||||
Loading…
Add table
Reference in a new issue