mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-28 06:23:07 +00:00
Merge 57eb662761 into b6f91a221b
This commit is contained in:
commit
b7feeb461c
29 changed files with 443 additions and 75 deletions
32
Dockerfile
32
Dockerfile
|
|
@ -54,17 +54,21 @@ USER root
|
|||
COPY --from=uvbin /uv /usr/local/bin/uv
|
||||
COPY --from=uvbin /uvx /usr/local/bin/uvx
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
gcc \
|
||||
python-3.13 \
|
||||
python-3.13-dev \
|
||||
rust \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
nodejs \
|
||||
npm \
|
||||
libsndfile
|
||||
RUN for attempt in 1 2 3 4 5; do \
|
||||
apk add --no-cache \
|
||||
bash \
|
||||
gcc \
|
||||
python-3.13 \
|
||||
python-3.13-dev \
|
||||
rust \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
nodejs \
|
||||
npm \
|
||||
libsndfile && break; \
|
||||
[ "$attempt" -eq 5 ] && exit 1; \
|
||||
sleep 3; \
|
||||
done
|
||||
|
||||
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
|
||||
UV_LINK_MODE=copy \
|
||||
|
|
@ -141,7 +145,11 @@ USER root
|
|||
RUN echo "https://packages.wolfi.dev/os" >> /etc/apk/repositories
|
||||
|
||||
# node (without npm) is required by the prisma CLI at runtime
|
||||
RUN apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent
|
||||
RUN for attempt in 1 2 3 4 5; do \
|
||||
apk add --no-cache bash openssl tzdata nodejs python-3.13 libsndfile libevent && break; \
|
||||
[ "$attempt" -eq 5 ] && exit 1; \
|
||||
sleep 3; \
|
||||
done
|
||||
COPY --from=pgbouncer-builder /usr/local/bin/pgbouncer /usr/local/bin/pgbouncer
|
||||
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -3914,6 +3914,17 @@
|
|||
],
|
||||
"title": "Team Id"
|
||||
},
|
||||
"user_alias": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "User Alias"
|
||||
},
|
||||
"user_email": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ class _KeyMetadataDict(TypedDict, total=False):
|
|||
team_id: ReadOnly[str | None]
|
||||
user_id: ReadOnly[str | None]
|
||||
user_email: ReadOnly[str | None]
|
||||
user_alias: ReadOnly[str | None]
|
||||
key_exists: ReadOnly[bool]
|
||||
|
||||
|
||||
|
|
@ -183,6 +184,7 @@ def _key_metadata(api_key_metadata: Mapping[str, KeyMetadataRow], api_key: str)
|
|||
team_id=meta.team_id if meta is not None else None,
|
||||
user_id=meta.user_id if meta is not None else None,
|
||||
user_email=meta.user_email if meta is not None else None,
|
||||
user_alias=meta.user_alias if meta is not None else None,
|
||||
key_exists=meta.key_exists if meta is not None else False,
|
||||
)
|
||||
|
||||
|
|
@ -492,6 +494,7 @@ class _ProxyDailyActivityReads:
|
|||
"team_id": row.team_id,
|
||||
"user_id": row.user_id,
|
||||
"user_email": row.user_email,
|
||||
"user_alias": row.user_alias,
|
||||
"key_exists": row.key_exists,
|
||||
}
|
||||
for key, row in resolved.items()
|
||||
|
|
@ -534,6 +537,7 @@ class _ProxyDailyActivityReads:
|
|||
team_id=value.get("team_id"),
|
||||
user_id=value.get("user_id"),
|
||||
user_email=value.get("user_email"),
|
||||
user_alias=value.get("user_alias"),
|
||||
key_exists=value.get("key_exists", False),
|
||||
)
|
||||
if key in resolved
|
||||
|
|
@ -545,6 +549,7 @@ class _ProxyDailyActivityReads:
|
|||
user_email=value.get("user_email"),
|
||||
key_exists=value.get("key_exists", False),
|
||||
tags=(),
|
||||
user_alias=value.get("user_alias"),
|
||||
)
|
||||
for key, value in attached.items()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ def _key_metadata(api_key: str, metadata: Mapping[str, KeyMetadataRow]) -> KeyMe
|
|||
team_id=row.team_id,
|
||||
user_id=row.user_id,
|
||||
user_email=row.user_email,
|
||||
user_alias=row.user_alias,
|
||||
key_exists=row.key_exists,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ class KeyMetadataDict(TypedDict, total=False):
|
|||
team_id: ReadOnly[str | None]
|
||||
user_id: ReadOnly[str | None]
|
||||
user_email: ReadOnly[str | None]
|
||||
user_alias: ReadOnly[str | None]
|
||||
key_exists: ReadOnly[bool]
|
||||
|
||||
|
||||
|
|
@ -249,6 +250,7 @@ async def recover_key_owner_from_daily_spend(
|
|||
@dataclass(frozen=True, slots=True)
|
||||
class _UserDetails:
|
||||
email: str | None
|
||||
alias: str | None
|
||||
only_team: str | None
|
||||
|
||||
|
||||
|
|
@ -276,6 +278,7 @@ async def _details_for_user_ids(
|
|||
{
|
||||
user.user_id: _UserDetails(
|
||||
email=getattr(user, "user_email", None) or None,
|
||||
alias=getattr(user, "user_alias", None) or None,
|
||||
only_team=_only_team(getattr(user, "teams", None)),
|
||||
)
|
||||
for user in users
|
||||
|
|
@ -303,10 +306,12 @@ def _meta_with_user_details(
|
|||
return meta
|
||||
user: Final = details[user_id]
|
||||
email: Final = meta.get("user_email") or user.email
|
||||
alias: Final = meta.get("user_alias") or user.alias
|
||||
team_id: Final = meta.get("team_id") or (user.only_team if _is_cli_session_key(api_key) else None)
|
||||
updated: Final[KeyMetadataDict] = {
|
||||
**meta,
|
||||
**({"user_email": email} if email else {}),
|
||||
**({"user_alias": alias} if alias else {}),
|
||||
**({"team_id": team_id} if team_id else {}),
|
||||
}
|
||||
return updated
|
||||
|
|
|
|||
|
|
@ -314,6 +314,7 @@ class DailyActivityRepository:
|
|||
user_email=None,
|
||||
key_exists=key_exists,
|
||||
tags=tags,
|
||||
user_alias=None,
|
||||
)
|
||||
|
||||
async def daily_rows(self, scope: DailyActivityScope, *, page: int, page_size: int) -> DailyRowsPage:
|
||||
|
|
|
|||
|
|
@ -1487,6 +1487,10 @@ class LiteLLMCompletionResponsesConfig:
|
|||
already carries, because Anthropic and Bedrock verify signatures against
|
||||
the original block order.
|
||||
|
||||
A reasoning item replayed *after* the tool calls of its own turn rides
|
||||
on that assistant message instead of becoming a standalone one, so the
|
||||
tool results keep following the assistant that requested them.
|
||||
|
||||
If the reasoning item is not followed by an assistant message (e.g. a
|
||||
stateless chain replays ``reasoning`` + ``user``), the standalone
|
||||
reasoning message is preserved so the reasoning is still passed back.
|
||||
|
|
@ -1573,7 +1577,10 @@ class LiteLLMCompletionResponsesConfig:
|
|||
pending.append((_reasoning_text(msg), _thinking_blocks(msg)))
|
||||
continue
|
||||
|
||||
if pending and _role(msg) == "assistant":
|
||||
if pending and merged and _role(merged[-1]) == "assistant" and _tool_calls(merged[-1]):
|
||||
_apply_pending(merged[-1], pending)
|
||||
pending = []
|
||||
elif pending and _role(msg) == "assistant":
|
||||
_apply_pending(msg, pending)
|
||||
pending = []
|
||||
elif pending:
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class KeyMetadata(LiteLLMBaseModel):
|
|||
team_id: str | None = None
|
||||
user_id: str | None = None
|
||||
user_email: str | None = None
|
||||
user_alias: str | None = None
|
||||
key_exists: bool | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ class KeyMetadataRow:
|
|||
user_email: str | None
|
||||
key_exists: bool
|
||||
tags: tuple[str, ...]
|
||||
user_alias: str | None = None
|
||||
|
||||
|
||||
class ExportType(str, Enum):
|
||||
|
|
|
|||
|
|
@ -569,6 +569,39 @@ async def test_recovered_key_metadata_preserves_resolved_tags_after_user_details
|
|||
attach_details.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovered_key_metadata_carries_the_user_alias(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
resolved: Final = KeyMetadataRow(
|
||||
api_key="key-hash",
|
||||
key_alias=None,
|
||||
team_id=None,
|
||||
user_id="user-id",
|
||||
user_email=None,
|
||||
key_exists=True,
|
||||
tags=(),
|
||||
)
|
||||
attach_details: Final = AsyncMock(
|
||||
return_value={
|
||||
"key-hash": {
|
||||
"key_alias": None,
|
||||
"team_id": None,
|
||||
"user_id": "user-id",
|
||||
"user_email": "user@example.com",
|
||||
"user_alias": "Alice A",
|
||||
"key_exists": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(common_daily_activity_module, "attach_user_details", attach_details)
|
||||
reads: Final = _ProxyDailyActivityReads(MagicMock())
|
||||
|
||||
result: Final = await reads.recover_key_metadata({"key-hash": resolved}, frozenset(("key-hash",)), None)
|
||||
|
||||
assert result["key-hash"].user_alias == "Alice A"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_api_key_metadata_falls_back_to_deleted_keys():
|
||||
"""Test that get_api_key_metadata should fall back to deleted keys table for missing keys."""
|
||||
|
|
@ -926,6 +959,28 @@ def test_key_metadata_includes_user_id_without_user_email():
|
|||
assert meta.user_email is None
|
||||
|
||||
|
||||
def test_key_metadata_includes_recovered_user_alias():
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import _key_metadata
|
||||
|
||||
meta = _key_metadata(
|
||||
{
|
||||
"dirty-key": KeyMetadataRow(
|
||||
api_key="dirty-key",
|
||||
key_alias=None,
|
||||
team_id="team-1",
|
||||
user_id="user-123",
|
||||
user_email=None,
|
||||
key_exists=True,
|
||||
tags=(),
|
||||
user_alias="Alice A",
|
||||
)
|
||||
},
|
||||
"dirty-key",
|
||||
)
|
||||
|
||||
assert meta.user_alias == "Alice A"
|
||||
|
||||
|
||||
def test_update_breakdown_metrics_includes_user_email():
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import update_breakdown_metrics
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import BreakdownMetrics
|
||||
|
|
|
|||
|
|
@ -756,6 +756,23 @@ async def test_attach_user_details_claims_no_team_for_a_multi_team_user_session_
|
|||
assert attached["cli-session-bob"]["user_email"] == "bob@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_user_details_carries_the_user_alias():
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_usertable.find_many = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(user_id="alice", user_email="alice@example.com", user_alias="Alice A", teams=[])
|
||||
]
|
||||
)
|
||||
personal_key = hash_token("sk-personal")
|
||||
|
||||
attached = await attach_user_details(
|
||||
mock_prisma, {personal_key: {"key_alias": "personal", "user_id": "alice"}}
|
||||
)
|
||||
|
||||
assert attached[personal_key]["user_alias"] == "Alice A"
|
||||
|
||||
|
||||
def _user_lookup_by_filter() -> AsyncMock:
|
||||
async def find_many(*, where):
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -157,3 +157,39 @@ def test_assistant_message_before_function_call_keeps_one_assistant_turn():
|
|||
assistant_idx = msgs.index(assistant)
|
||||
assert msgs[assistant_idx + 1].get("role") == "tool"
|
||||
assert msgs[assistant_idx + 1].get("tool_call_id") == "call_1"
|
||||
|
||||
|
||||
def test_reasoning_replayed_after_the_tool_call_keeps_the_tool_output_adjacent():
|
||||
"""DeepSeek rejects a tool call whose turn replays its reasoning after the calls."""
|
||||
msgs = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
|
||||
input=[
|
||||
{
|
||||
"role": "user",
|
||||
"type": "message",
|
||||
"content": [{"type": "input_text", "text": "List the files."}],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "shell_command",
|
||||
"call_id": "call_42",
|
||||
"arguments": '{"command": "ls"}',
|
||||
},
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [{"type": "summary_text", "text": "The user wants the files listed."}],
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_42",
|
||||
"output": "file1\nfile2",
|
||||
},
|
||||
],
|
||||
replay_reasoning=True,
|
||||
)
|
||||
|
||||
assert [message["role"] for message in msgs] == ["user", "assistant", "tool"]
|
||||
assistant = msgs[1]
|
||||
assert [call["id"] for call in assistant["tool_calls"]] == ["call_42"]
|
||||
assert assistant["reasoning_content"] == "The user wants the files listed."
|
||||
assert msgs[2]["tool_call_id"] == "call_42"
|
||||
|
|
|
|||
|
|
@ -540,6 +540,36 @@ describe("EntityUsage", () => {
|
|||
expect(getTopAPIKeys(results, 5)[0]?.user).toBe("user-123");
|
||||
});
|
||||
|
||||
it("names the owner by alias rather than email for global and entity top keys", () => {
|
||||
const results = [
|
||||
createDailyData("2025-01-01", {
|
||||
"key-123": createKeyMetrics(12.5, {
|
||||
key_alias: null,
|
||||
team_id: null,
|
||||
user_id: "user-123",
|
||||
user_email: "alice@example.com",
|
||||
user_alias: "Alice Chen",
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
expect(getGlobalTopKeys(results, 5)[0]?.key_alias).toBe("Alice Chen");
|
||||
expect(getGlobalTopKeys(results, 5)[0]?.user).toBe("Alice Chen");
|
||||
expect(getTopAPIKeys(results, 5)[0]?.key_alias).toBe("Alice Chen");
|
||||
expect(getTopAPIKeys(results, 5)[0]?.user).toBe("Alice Chen");
|
||||
});
|
||||
|
||||
it("labels keys with their owner when no alias or email was recorded", () => {
|
||||
const results = [
|
||||
createDailyData("2025-01-01", {
|
||||
"key-123": createKeyMetrics(12.5, { key_alias: null, team_id: null, user_id: "user-123" }),
|
||||
}),
|
||||
];
|
||||
|
||||
expect(getGlobalTopKeys(results, 5)[0]?.key_alias).toBe("user-123");
|
||||
expect(getTopAPIKeys(results, 5)[0]?.key_alias).toBe("user-123");
|
||||
});
|
||||
|
||||
it("carries whether each key still exists for global and entity top keys", () => {
|
||||
const results = [
|
||||
createDailyData("2025-01-01", {
|
||||
|
|
@ -1159,6 +1189,37 @@ describe("EntityUsage", () => {
|
|||
expect(screen.queryByText(userUuid)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefers the user alias when labelling an owner who has both a name and an email", async () => {
|
||||
const userUuid = "c0e68be8-057e-4e2f-9d3a-000000000001";
|
||||
const spendDataForUser = {
|
||||
...mockSpendData,
|
||||
results: [
|
||||
{
|
||||
...mockSpendData.results[0],
|
||||
breakdown: {
|
||||
...mockSpendData.results[0].breakdown,
|
||||
entities: {
|
||||
[userUuid]: {
|
||||
...mockSpendData.results[0].breakdown.entities["tag-1"],
|
||||
metadata: { user_alias: "Chen Yan", user_email: "spender@example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUserDailyActivityCall.mockResolvedValue(spendDataForUser);
|
||||
|
||||
// entityList is null to simulate a spender missing from the paginated user list
|
||||
render(<StatefulEntityUsage {...defaultProps} entityType="user" entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Chen Yan")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("spender@example.com")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the provider spend table logo from the bundled provider map", async () => {
|
||||
render(<StatefulEntityUsage {...defaultProps} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -272,14 +272,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
if (metadata?.team_alias) {
|
||||
return metadata.team_alias;
|
||||
}
|
||||
// Resolve user_id to email/alias so the Spend Per User chart never shows a raw UUID
|
||||
// when an email is on file (the entityList is paginated and may miss spenders)
|
||||
if (metadata?.user_email) {
|
||||
return metadata.user_email;
|
||||
}
|
||||
// Resolve user_id to the owner's name or email so the Spend Per User chart never
|
||||
// shows a raw UUID when either is on file (the entityList is paginated and may
|
||||
// miss spenders)
|
||||
if (metadata?.user_alias) {
|
||||
return metadata.user_alias;
|
||||
}
|
||||
if (metadata?.user_email) {
|
||||
return metadata.user_email;
|
||||
}
|
||||
return entity;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To
|
|||
team_id: null,
|
||||
user_id: metrics.metadata.user_id,
|
||||
user_email: metrics.metadata.user_email,
|
||||
user_alias: metrics.metadata.user_alias,
|
||||
key_exists: metrics.metadata.key_exists,
|
||||
tags: metrics.metadata.tags || [],
|
||||
},
|
||||
|
|
@ -128,8 +129,8 @@ export const getGlobalTopKeys = (results: DailyData[], topKeysLimit: number): To
|
|||
return Object.entries(keySpend)
|
||||
.map(([api_key, metrics]) => ({
|
||||
api_key,
|
||||
key_alias: keyActivityLabel(metrics.metadata),
|
||||
user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
|
||||
key_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"),
|
||||
user: metrics.metadata.user_alias ?? metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
|
||||
key_exists: metrics.metadata.key_exists,
|
||||
tags: metrics.metadata.tags || [],
|
||||
spend: metrics.metrics.spend,
|
||||
|
|
@ -174,6 +175,7 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
|
|||
team_id: metrics.metadata.team_id || null,
|
||||
user_id: metrics.metadata.user_id,
|
||||
user_email: metrics.metadata.user_email,
|
||||
user_alias: metrics.metadata.user_alias,
|
||||
key_exists: metrics.metadata.key_exists,
|
||||
tags: tagDictionary[key] || [],
|
||||
},
|
||||
|
|
@ -194,8 +196,8 @@ export const getTopAPIKeys = (results: ExtendedDailyData[], topKeysLimit: number
|
|||
return Object.entries(keySpend)
|
||||
.map(([api_key, metrics]) => ({
|
||||
api_key,
|
||||
key_alias: keyActivityLabel(metrics.metadata),
|
||||
user: metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
|
||||
key_alias: keyActivityLabel(metrics.metadata, metrics.metadata.user_id ?? "-"),
|
||||
user: metrics.metadata.user_alias ?? metrics.metadata.user_email ?? metrics.metadata.user_id ?? null,
|
||||
key_exists: metrics.metadata.key_exists,
|
||||
tags: metrics.metadata.tags || [],
|
||||
spend: metrics.metrics.spend,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ describe("teamUserSpend", () => {
|
|||
expect(ids[0]).not.toBe(ids[1]);
|
||||
});
|
||||
|
||||
it("labels a user by email, then alias, then id, then a placeholder", () => {
|
||||
it("labels a user by alias, then email, then id, then a placeholder", () => {
|
||||
expect(userLabel(row({ user_alias: "Alice C" }))).toBe("Alice C");
|
||||
expect(userLabel(row({}))).toBe("alice@example.com");
|
||||
expect(userLabel(row({ user_email: null, user_alias: "Bob", user_id: "u1" }))).toBe("Bob");
|
||||
expect(userLabel(row({ user_email: null, user_alias: null, user_id: "u1" }))).toBe("u1");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export type TeamUserSpendRow = TeamUserSpendResponse["results"][number];
|
|||
export const NO_USER_LABEL = "(no user)";
|
||||
|
||||
export const userLabel = (row: TeamUserSpendRow): string => {
|
||||
const identity = row.user_email || row.user_alias;
|
||||
const identity = row.user_alias || row.user_email;
|
||||
return identity || row.user_id || NO_USER_LABEL;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -826,6 +826,32 @@ describe("UsagePage", () => {
|
|||
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should render one daily spend bar when the aggregated response repeats a date", async () => {
|
||||
const repeatedDay = mockSpendData.results[0];
|
||||
mockUserDailyActivityAggregatedCall.mockResolvedValue({
|
||||
...mockSpendData,
|
||||
results: [repeatedDay, { ...repeatedDay, metrics: { ...repeatedDay.metrics } }],
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const spendBars = () => {
|
||||
const gatewayCard = container.querySelector('[data-testid="gateway-requests-by-endpoint"]');
|
||||
return Array.from(container.querySelectorAll("path.recharts-rectangle")).filter(
|
||||
(rect) => !gatewayCard?.contains(rect),
|
||||
);
|
||||
};
|
||||
|
||||
// The repeated date collapses into one day stacked as the top model over the remainder.
|
||||
await waitFor(() => {
|
||||
expect(spendBars()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should switch between usage views correctly", async () => {
|
||||
renderWithProviders(<UsagePage {...defaultProps} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
type DailyActivityRequest,
|
||||
} from "@/components/UsagePage/dailyActivityApi";
|
||||
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import {
|
||||
fetchedRangeKey,
|
||||
selectForRange,
|
||||
|
|
@ -331,10 +332,25 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
[userSpendData.results, topKeysLimit],
|
||||
);
|
||||
|
||||
const sortedDailyResults = useMemo(
|
||||
() => [...userSpendData.results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()),
|
||||
[userSpendData.results],
|
||||
);
|
||||
const sortedDailyResults = useMemo(() => {
|
||||
const resultsByDate = new Map<string, DailyData>();
|
||||
|
||||
userSpendData.results.forEach((result) => {
|
||||
const existing = resultsByDate.get(result.date);
|
||||
if (!existing) {
|
||||
resultsByDate.set(result.date, { ...result, metrics: { ...result.metrics } });
|
||||
return;
|
||||
}
|
||||
|
||||
existing.metrics.spend += result.metrics.spend;
|
||||
existing.metrics.api_requests += result.metrics.api_requests;
|
||||
existing.metrics.successful_requests += result.metrics.successful_requests;
|
||||
existing.metrics.failed_requests += result.metrics.failed_requests;
|
||||
existing.metrics.total_tokens += result.metrics.total_tokens;
|
||||
});
|
||||
|
||||
return [...resultsByDate.values()].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
}, [userSpendData.results]);
|
||||
const gatewayRequestsByRoute = useMemo(() => topGatewayRoutes(gatewayActivity), [gatewayActivity]);
|
||||
const modelMetrics = useMemo(
|
||||
() => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeyResponse } from "../../../key_team_helpers/key_list";
|
||||
import * as transformKeyInfo from "../../../key_team_helpers/transform_key_info";
|
||||
import * as networking from "../../../networking";
|
||||
import TopKeyView from "./TopKeyView";
|
||||
|
||||
|
|
@ -13,11 +12,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../../../networking", () => ({
|
||||
keyInfoV1Call: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../key_team_helpers/transform_key_info", () => ({
|
||||
transformKeyInfo: vi.fn(),
|
||||
keyListCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../templates/key_info_view", () => ({
|
||||
|
|
@ -33,8 +28,7 @@ const chartBars = (container: HTMLElement) => Array.from(container.querySelector
|
|||
|
||||
describe("TopKeyView", () => {
|
||||
const mockUseAuthorized = vi.mocked(useAuthorized);
|
||||
const mockKeyInfoV1Call = vi.mocked(networking.keyInfoV1Call);
|
||||
const mockTransformKeyInfo = vi.mocked(transformKeyInfo.transformKeyInfo);
|
||||
const mockKeyListCall = vi.mocked(networking.keyListCall);
|
||||
|
||||
const mockAuth = {
|
||||
token: "mock-token",
|
||||
|
|
@ -60,8 +54,7 @@ describe("TopKeyView", () => {
|
|||
beforeEach(() => {
|
||||
mockUseAuthorized.mockReturnValue(mockAuth);
|
||||
mockSetTopKeysLimit.mockClear();
|
||||
mockKeyInfoV1Call.mockClear();
|
||||
mockTransformKeyInfo.mockClear();
|
||||
mockKeyListCall.mockClear();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
|
|
@ -186,10 +179,8 @@ describe("TopKeyView", () => {
|
|||
});
|
||||
|
||||
it("renders brand-blue bars with truncated aliases in chart view and opens the key info modal on bar click", async () => {
|
||||
const mockKeyInfo = { key: "info" };
|
||||
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
|
||||
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
|
||||
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
|
||||
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
|
|
@ -216,7 +207,19 @@ describe("TopKeyView", () => {
|
|||
fireEvent.click(bars[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123");
|
||||
expect(mockKeyListCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"key-123",
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
"user",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -459,10 +462,8 @@ describe("TopKeyView", () => {
|
|||
});
|
||||
|
||||
it("should open modal when key ID is clicked", async () => {
|
||||
const mockKeyInfo = { key: "info" };
|
||||
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
|
||||
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
|
||||
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
|
||||
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
|
@ -485,7 +486,19 @@ describe("TopKeyView", () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123");
|
||||
expect(mockKeyListCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"key-123",
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
"user",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -494,8 +507,8 @@ describe("TopKeyView", () => {
|
|||
});
|
||||
|
||||
it("should only look up keys that still exist in the database, from both the table and the chart", async () => {
|
||||
mockKeyInfoV1Call.mockResolvedValue({ key: "info" });
|
||||
mockTransformKeyInfo.mockReturnValue({ transformed: "data" } as unknown as KeyResponse);
|
||||
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
|
||||
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
|
|
@ -518,15 +531,25 @@ describe("TopKeyView", () => {
|
|||
bars.forEach((bar) => fireEvent.click(bar));
|
||||
|
||||
expect(await screen.findByText("Key Info View for stored-key")).toBeInTheDocument();
|
||||
expect(mockKeyInfoV1Call).toHaveBeenCalledTimes(1);
|
||||
expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "stored-key");
|
||||
expect(mockKeyListCall).toHaveBeenCalledTimes(1);
|
||||
expect(mockKeyListCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"stored-key",
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
"user",
|
||||
);
|
||||
});
|
||||
|
||||
it("should close modal when close button is clicked", async () => {
|
||||
const mockKeyInfo = { key: "info" };
|
||||
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
|
||||
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
|
||||
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
|
||||
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
|
@ -561,10 +584,8 @@ describe("TopKeyView", () => {
|
|||
});
|
||||
|
||||
it("should close modal when escape key is pressed", async () => {
|
||||
const mockKeyInfo = { key: "info" };
|
||||
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
|
||||
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
|
||||
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
|
||||
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
|
@ -598,10 +619,8 @@ describe("TopKeyView", () => {
|
|||
});
|
||||
|
||||
it("should close modal when clicking outside modal", async () => {
|
||||
const mockKeyInfo = { key: "info" };
|
||||
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
|
||||
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
|
||||
mockTransformKeyInfo.mockReturnValue(mockTransformedData);
|
||||
mockKeyListCall.mockResolvedValue({ keys: [mockTransformedData] });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
|
|
@ -664,7 +683,7 @@ describe("TopKeyView", () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyInfoV1Call).not.toHaveBeenCalled();
|
||||
expect(mockKeyListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("key-info-view")).not.toBeInTheDocument();
|
||||
|
|
@ -672,7 +691,7 @@ describe("TopKeyView", () => {
|
|||
|
||||
it("should handle error when fetching key info", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
mockKeyInfoV1Call.mockRejectedValue(new Error("Network error"));
|
||||
mockKeyListCall.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
|
@ -695,7 +714,7 @@ describe("TopKeyView", () => {
|
|||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockKeyInfoV1Call).toHaveBeenCalled();
|
||||
expect(mockKeyListCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { Segmented } from "@/app/(dashboard)/usage/_components/components/overvi
|
|||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import React, { useState } from "react";
|
||||
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
|
||||
import { transformKeyInfo } from "../../../key_team_helpers/transform_key_info";
|
||||
import { keyInfoV1Call } from "../../../networking";
|
||||
import type { KeyResponse } from "../../../key_team_helpers/key_list";
|
||||
import { keyListCall } from "../../../networking";
|
||||
import KeyInfoView from "../../../templates/key_info_view";
|
||||
import { TagUsage } from "../../types";
|
||||
|
||||
|
|
@ -70,10 +70,14 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
|
|||
if (!accessToken || !canOpenKeyInfo(item)) return;
|
||||
|
||||
try {
|
||||
const keyInfo = await keyInfoV1Call(accessToken, item.api_key);
|
||||
const transformedKeyData = transformKeyInfo(keyInfo);
|
||||
const keyList = await keyListCall(accessToken, null, null, null, null, item.api_key, 1, 1, null, null, "user");
|
||||
const expandedKey = keyList.keys[0] as KeyResponse | undefined;
|
||||
if (!expandedKey) return;
|
||||
|
||||
setKeyData(transformedKeyData);
|
||||
setKeyData({
|
||||
...expandedKey,
|
||||
user_email: expandedKey.user?.user_email ?? expandedKey.user_email,
|
||||
});
|
||||
setSelectedKey(item.api_key);
|
||||
setIsModalOpen(true); // Open modal when key is clicked
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export const toKeyMetadata = (metadata: components["schemas"]["KeyMetadata"] | u
|
|||
team_id: metadata?.team_id ?? null,
|
||||
user_id: metadata?.user_id,
|
||||
user_email: metadata?.user_email,
|
||||
user_alias: metadata?.user_alias,
|
||||
key_exists: metadata?.key_exists,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ describe("keyActivityLabel", () => {
|
|||
expect(keyActivityLabel({ key_alias: null, user_email: "alice@example.com" })).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("prefers the user alias over the user email", () => {
|
||||
expect(keyActivityLabel({ key_alias: null, user_alias: "Alice A", user_email: "alice@example.com" })).toBe(
|
||||
"Alice A",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the fallback when both alias and email are missing", () => {
|
||||
expect(keyActivityLabel({ key_alias: null, user_email: null }, "key-hash-abc")).toBe("key-hash-abc");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { KeyMetadata } from "./types";
|
||||
|
||||
export function keyActivityLabel(
|
||||
metadata: Pick<KeyMetadata, "key_alias" | "user_email"> | null | undefined,
|
||||
metadata: Pick<KeyMetadata, "key_alias" | "user_alias" | "user_email"> | null | undefined,
|
||||
fallback = "-",
|
||||
): string {
|
||||
return metadata?.key_alias || metadata?.user_email || fallback;
|
||||
const byAlias = metadata?.key_alias || metadata?.user_alias;
|
||||
return byAlias || metadata?.user_email || fallback;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface KeyMetadata {
|
|||
team_id: string | null;
|
||||
user_id?: string | null;
|
||||
user_email?: string | null;
|
||||
user_alias?: string | null;
|
||||
key_exists?: boolean | null;
|
||||
tags?: { tag: string; usage: number }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,6 +259,35 @@ describe("KeyInfoView", () => {
|
|||
expect(screen.queryByRole("tab", { name: "Auto-router usage" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("key alias row", () => {
|
||||
const renderKeyAliasRow = async (keyData: Partial<KeyResponse>) => {
|
||||
vi.mocked(useAuthorized).mockReturnValue({ ...baseUseAuthorizedMock, userRole: "Admin" });
|
||||
renderWithProviders(
|
||||
<KeyInfoView keyData={{ ...MOCK_KEY_DATA, ...keyData }} onClose={() => {}} keyId="test-key-id" teams={[]} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
return screen.getByText("Key Alias").parentElement;
|
||||
};
|
||||
|
||||
it("shows the key owner when the key has no alias", async () => {
|
||||
const row = await renderKeyAliasRow({
|
||||
key_alias: null,
|
||||
user: { user_id: "790", user_email: "chenyan@example.com", user_alias: "陈岩" },
|
||||
});
|
||||
|
||||
expect(row).toHaveTextContent("陈岩");
|
||||
});
|
||||
|
||||
it("keeps a configured alias ahead of the key owner", async () => {
|
||||
const row = await renderKeyAliasRow({
|
||||
key_alias: "batch-worker",
|
||||
user: { user_id: "790", user_email: "chenyan@example.com", user_alias: "陈岩" },
|
||||
});
|
||||
|
||||
expect(row).toHaveTextContent("batch-worker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("last updated", () => {
|
||||
const renderWithTimestamps = (overrides: Partial<KeyResponse>) => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
|
|
|||
|
|
@ -563,6 +563,8 @@ export default function KeyInfoView({
|
|||
return "Unlimited";
|
||||
})();
|
||||
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser, memberGate);
|
||||
const keyOwnerLabel = currentKeyData.user?.user_alias || currentKeyData.user_email || currentKeyData.user_id;
|
||||
const keyAliasLabel = currentKeyData.key_alias || keyOwnerLabel || "Not Set";
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto p-4">
|
||||
|
|
@ -931,7 +933,7 @@ export default function KeyInfoView({
|
|||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Key Alias</p>
|
||||
<p className="text-sm">{currentKeyData.key_alias || "Not Set"}</p>
|
||||
<p className="text-sm">{keyAliasLabel}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -33781,6 +33781,8 @@ export interface components {
|
|||
key_exists?: boolean | null;
|
||||
/** Team Id */
|
||||
team_id?: string | null;
|
||||
/** User Alias */
|
||||
user_alias?: string | null;
|
||||
/** User Email */
|
||||
user_email?: string | null;
|
||||
/** User Id */
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders, screen, fireEvent } from "./test-utils";
|
||||
import { renderWithProviders, screen, fireEvent, waitFor } from "./test-utils";
|
||||
import TopKeyView from "../src/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import { TagUsage } from "../src/components/UsagePage/types";
|
||||
import useAuthorized from "../src/app/(dashboard)/hooks/useAuthorized";
|
||||
import { keyListCall } from "../src/components/networking";
|
||||
|
||||
// Mock the networking module
|
||||
vi.mock("../src/components/networking", () => ({
|
||||
keyInfoV1Call: vi.fn(),
|
||||
keyListCall: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the transform function
|
||||
vi.mock("../src/components/key_team_helpers/transform_key_info", () => ({
|
||||
transformKeyInfo: vi.fn((data) => data),
|
||||
vi.mock("../src/components/templates/key_info_view", () => ({
|
||||
default: ({ keyData }: { keyData: { user_id?: string; user_email?: string; user?: { user_alias?: string } } }) => (
|
||||
<div>{`Owner: ${keyData.user?.user_alias}|${keyData.user_email}|${keyData.user_id}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../src/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
|
|
@ -20,6 +22,7 @@ vi.mock("../src/app/(dashboard)/hooks/useAuthorized", () => ({
|
|||
|
||||
describe("TopKeyView", () => {
|
||||
const mockUseAuthorized = vi.mocked(useAuthorized);
|
||||
const mockKeyListCall = vi.mocked(keyListCall);
|
||||
const mockProps = {
|
||||
topKeys: [],
|
||||
accessToken: "test-token",
|
||||
|
|
@ -321,5 +324,50 @@ describe("TopKeyView", () => {
|
|||
// Check that spend is formatted correctly
|
||||
expect(screen.getByText("$25.50")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand the owner when opening key details", async () => {
|
||||
mockKeyListCall.mockResolvedValue({
|
||||
keys: [
|
||||
{
|
||||
token: "key-1",
|
||||
user_id: "user-1",
|
||||
user_email: null,
|
||||
user: {
|
||||
user_id: "user-1",
|
||||
user_alias: "Alice Example",
|
||||
user_email: "alice@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as Awaited<ReturnType<typeof keyListCall>>);
|
||||
|
||||
renderWithProviders(
|
||||
<TopKeyView
|
||||
{...mockProps}
|
||||
topKeys={[{ api_key: "key-1", key_alias: "Test Key", spend: 25.5 }]}
|
||||
topKeysLimit={5}
|
||||
setTopKeysLimit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("key-1"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockKeyListCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"key-1",
|
||||
1,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
"user",
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("Owner: Alice Example|alice@example.com|user-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue