refactor(lens)!: rename internal engine code and API (#44034)
* chore(lens): remove deployment screenshots * refactor(lens)!: rename internal engine package and API * fix(lens): pin worker image for renamed API * test(lens): cover fresh and populated rename migrations * fix(lens): protect db-push upgrades and restore routing and CI * fix(lens): resolve migration tables across schemas and include database driver
|
|
@ -107,7 +107,7 @@ legacy_paths() {
|
|||
echo tests/unit/proxy/test_update_spend.py
|
||||
echo tests/unit/skills/test_skills_db.py ;;
|
||||
proxy-db-endpoints-and-responses)
|
||||
echo tests/unit/proxy/engine
|
||||
echo tests/unit/proxy/lens
|
||||
echo tests/unit/proxy/auth/test_models_fallback_endpoint.py
|
||||
echo tests/unit/proxy/common_utils/test_check_batch_cost.py
|
||||
echo tests/unit/proxy/common_utils/test_check_responses_cost.py
|
||||
|
|
|
|||
10
.github/workflows/lens-worker.yml
vendored
|
|
@ -5,13 +5,13 @@ on:
|
|||
branches: [main, litellm_oss_branch, "litellm_**"]
|
||||
paths:
|
||||
- deploy/lens/**
|
||||
- litellm/proxy/engine/**
|
||||
- litellm/proxy/lens/**
|
||||
- .github/workflows/lens-worker.yml
|
||||
push:
|
||||
branches: [main, litellm_agent_engine]
|
||||
branches: [main]
|
||||
paths:
|
||||
- deploy/lens/**
|
||||
- litellm/proxy/engine/**
|
||||
- litellm/proxy/lens/**
|
||||
- .github/workflows/lens-worker.yml
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
@ -41,8 +41,8 @@ jobs:
|
|||
--security-opt no-new-privileges --entrypoint python \
|
||||
lens-worker:${{ github.sha }} -c '
|
||||
import os
|
||||
import engine.worker
|
||||
from engine.trace_store import trace_store
|
||||
import lens.worker
|
||||
from lens.trace_store import trace_store
|
||||
assert os.getuid() == 65532
|
||||
with trace_store() as store:
|
||||
assert store.count() == 0
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
# Spend / analytics
|
||||
"/spend/",
|
||||
"/analytics/",
|
||||
"/engine/",
|
||||
"/lens/",
|
||||
"/v1/traces",
|
||||
"/global/",
|
||||
"/user_agent",
|
||||
|
|
@ -146,7 +146,7 @@ BACKEND_EXACT_PATHS: frozenset[str] = frozenset(
|
|||
{
|
||||
"/",
|
||||
"/routes",
|
||||
"/engine",
|
||||
"/lens",
|
||||
"/openapi.json",
|
||||
"/docs",
|
||||
"/docs/oauth2-redirect",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir httpx==0.28.1 pydantic==2.11.7
|
||||
COPY litellm/proxy/engine/__init__.py litellm/proxy/engine/models.py litellm/proxy/engine/trace_store.py litellm/proxy/engine/analysis.py litellm/proxy/engine/worker.py /app/engine/
|
||||
COPY litellm/proxy/lens/__init__.py litellm/proxy/lens/models.py litellm/proxy/lens/trace_store.py litellm/proxy/lens/analysis.py litellm/proxy/lens/worker.py /app/lens/
|
||||
USER 65532:65532
|
||||
CMD ["python", "-m", "engine.worker"]
|
||||
CMD ["python", "-m", "lens.worker"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
**
|
||||
!litellm/
|
||||
!litellm/proxy/
|
||||
!litellm/proxy/engine/
|
||||
!litellm/proxy/engine/__init__.py
|
||||
!litellm/proxy/engine/models.py
|
||||
!litellm/proxy/engine/analysis.py
|
||||
!litellm/proxy/engine/worker.py
|
||||
!litellm/proxy/lens/
|
||||
!litellm/proxy/lens/__init__.py
|
||||
!litellm/proxy/lens/models.py
|
||||
!litellm/proxy/lens/analysis.py
|
||||
!litellm/proxy/lens/worker.py
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ V1 requires ClickHouse for both sources. It does not reconstruct sessions from u
|
|||
The UI and API use the same scan lifecycle. Authenticate with a proxy administrator credential for writes, or a proxy-admin viewer credential for reads. Worker credentials are only for worker operations
|
||||
|
||||
```bash
|
||||
curl "$LITELLM_URL/engine" -H "Authorization: Bearer $LITELLM_API_KEY" \
|
||||
curl "$LITELLM_URL/lens" -H "Authorization: Bearer $LITELLM_API_KEY" \
|
||||
-H 'Content-Type: application/json' -d '{
|
||||
"name": "Research quality", "model": "your-model-alias",
|
||||
"context": "Answer the requested question using cited, retrieved evidence.",
|
||||
|
|
@ -83,14 +83,14 @@ curl "$LITELLM_URL/engine" -H "Authorization: Bearer $LITELLM_API_KEY" \
|
|||
"enabled": true, "interval_minutes": 1440, "monthly_budget": 50
|
||||
}'
|
||||
|
||||
curl "$LITELLM_URL/engine/$LENS_ID/runs" -X POST \
|
||||
curl "$LITELLM_URL/lens/$LENS_ID/runs" -X POST \
|
||||
-H "Authorization: Bearer $LITELLM_API_KEY" -H 'Content-Type: application/json' -d '{}'
|
||||
|
||||
curl "$LITELLM_URL/engine/$LENS_ID/runs?offset=0" -H "Authorization: Bearer $LITELLM_API_KEY"
|
||||
curl "$LITELLM_URL/engine/$LENS_ID/runs/$BATCH_ID" -H "Authorization: Bearer $LITELLM_API_KEY"
|
||||
curl "$LITELLM_URL/lens/$LENS_ID/runs?offset=0" -H "Authorization: Bearer $LITELLM_API_KEY"
|
||||
curl "$LITELLM_URL/lens/$LENS_ID/runs/$BATCH_ID" -H "Authorization: Bearer $LITELLM_API_KEY"
|
||||
```
|
||||
|
||||
Creation queues the first batch. Posting to `/engine/{id}/runs` queues another, or returns the existing active batch. The run response contains its ID under `jobs[0].id`. Poll the batch URL for status, findings and assessments. List responses omit large result payloads; request a batch to retrieve them. Supply an optional complete `settings` object on the runs POST for a one-off override; the saved lens stays unchanged. Selection accepts `team_id`, exact `filters`, and opaque `execution_ids` returned by `/engine/preview/sample`. Preview accepts `offset` and `as_of` to keep the time window fixed while paging. Feedback uses `PATCH /engine/{id}/findings/{finding_id}` with `status` and `reason`
|
||||
Creation queues the first batch. Posting to `/lens/{id}/runs` queues another, or returns the existing active batch. The run response contains its ID under `jobs[0].id`. Poll the batch URL for status, findings and assessments. List responses omit large result payloads; request a batch to retrieve them. Supply an optional complete `settings` object on the runs POST for a one-off override; the saved lens stays unchanged. Selection accepts `team_id`, exact `filters`, and opaque `execution_ids` returned by `/lens/preview/sample`. Preview accepts `offset` and `as_of` to keep the time window fixed while paging. Feedback uses `PATCH /lens/{id}/findings/{finding_id}` with `status` and `reason`
|
||||
|
||||
## Quality evaluation
|
||||
|
||||
|
|
@ -107,3 +107,11 @@ Set `LITELLM_API_KEY` privately. This makes paid model calls. Inspect missed and
|
|||
The worker uses temporary disk space for trace content while reviewing it, and removes those files after each review. The Docker command supplies a writable temporary mount while keeping the application filesystem read-only
|
||||
|
||||
To check that accepted behavior stays accepted without hiding new problems, run the evaluator with `--dataset tests/proxy_behavior/lens/feedback_cases.json`. Reports include elapsed time, model call count, reported cost when the proxy provides it, missed checks, unexpected checks, and inconclusive candidates
|
||||
|
||||
## Upgrading from the original Lens API
|
||||
|
||||
The Lens API now uses `/lens` instead of `/engine`, list responses use `lenses`, and worker claims use `lens_id`. Upgrade the proxy and recreate every worker with the image shown by the upgraded dashboard before starting new scans. Update API clients to the new paths and response fields. Old worker images cannot poll the renamed API
|
||||
|
||||
Stop workers and let active scans finish before upgrading. Deploy proxy instances together: older proxies cannot use the renamed database tables. The schema migration renames the three Lens tables and the run-history identifier column in place, preserving saved investigations, findings, history, worker credentials, and billing assignments. Existing migration files retain their original names and checksums
|
||||
|
||||
Upgrades using `--use_prisma_db_push` stop before schema changes if any legacy Lens table exists, preventing Prisma from dropping saved data. Apply `litellm-proxy-extras/litellm_proxy_extras/migrations/20261001100000_rename_lens/migration.sql` to the configured database schema before retrying. Deployments already using migration history can instead start without `--use_prisma_db_push` to apply the shipped migration normally. Fresh databases and databases already using the renamed tables can continue using database push
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
lens-worker:
|
||||
image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker@sha256:c41e932eaf3e4efbcaf8cc5027c7e93021e5b2823f21cb8785cd107e37b91c9a}
|
||||
image: ${LENS_WORKER_IMAGE:-ghcr.io/berriai/litellm-lens-worker@sha256:a8e8731d954916594eea462969946b9292fb771681ff515a9fd296b53f856c77}
|
||||
environment:
|
||||
LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container}
|
||||
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
|
@ -0,0 +1,18 @@
|
|||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE IF EXISTS "LiteLLM_Engine" RENAME TO "LiteLLM_Lens";
|
||||
ALTER TABLE IF EXISTS "LiteLLM_EngineRun" RENAME TO "LiteLLM_LensRun";
|
||||
ALTER TABLE IF EXISTS "LiteLLM_EngineWorker" RENAME TO "LiteLLM_LensWorker";
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = to_regclass('"LiteLLM_LensRun"')
|
||||
AND attname = 'engine_id' AND NOT attisdropped
|
||||
) THEN
|
||||
ALTER TABLE "LiteLLM_LensRun" RENAME COLUMN "engine_id" TO "lens_id";
|
||||
END IF;
|
||||
ALTER INDEX IF EXISTS "LiteLLM_Engine_pkey" RENAME TO "LiteLLM_Lens_pkey";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_EngineRun_pkey" RENAME TO "LiteLLM_LensRun_pkey";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_EngineWorker_pkey" RENAME TO "LiteLLM_LensWorker_pkey";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_EngineWorker_token_hash_key" RENAME TO "LiteLLM_LensWorker_token_hash_key";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_EngineRun_engine_id_created_at_idx" RENAME TO "LiteLLM_LensRun_lens_id_created_at_idx";
|
||||
END $$;
|
||||
|
|
@ -1895,22 +1895,22 @@ model LiteLLM_WorkflowMessage {
|
|||
@@index([run_id])
|
||||
}
|
||||
|
||||
model LiteLLM_Engine {
|
||||
model LiteLLM_Lens {
|
||||
id String @id
|
||||
version Int @default(0)
|
||||
data Json
|
||||
}
|
||||
|
||||
model LiteLLM_EngineRun {
|
||||
model LiteLLM_LensRun {
|
||||
id String @id
|
||||
engine_id String
|
||||
lens_id String
|
||||
created_at DateTime
|
||||
data Json
|
||||
|
||||
@@index([engine_id, created_at])
|
||||
@@index([lens_id, created_at])
|
||||
}
|
||||
|
||||
model LiteLLM_EngineWorker {
|
||||
model LiteLLM_LensWorker {
|
||||
id String @id
|
||||
token_hash String @unique
|
||||
data Json
|
||||
|
|
|
|||
|
|
@ -590,6 +590,36 @@ class ProxyExtrasDBManager:
|
|||
f"Failed to resolve migration {migration_name}: {e.stderr}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def raise_if_lens_rename_pending() -> None:
|
||||
database_url: Final = os.environ.get("DATABASE_URL")
|
||||
if not database_url:
|
||||
return
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Install psycopg to verify Lens data safety before prisma db push.") from exc
|
||||
try:
|
||||
with psycopg.connect(
|
||||
ProxyExtrasDBManager._strip_prisma_query_params(database_url), connect_timeout=10, autocommit=True
|
||||
) as connection:
|
||||
legacy: Final = connection.execute(
|
||||
"SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace "
|
||||
"WHERE n.nspname=%s AND c.relname IN ('LiteLLM_Engine', 'LiteLLM_EngineRun', 'LiteLLM_EngineWorker') "
|
||||
"LIMIT 1",
|
||||
(ProxyExtrasDBManager._prisma_schema_param(database_url) or "public",),
|
||||
).fetchone()
|
||||
except psycopg.Error as exc:
|
||||
raise RuntimeError(
|
||||
"Cannot verify Lens data safety; refusing prisma db push. Check database connectivity and psycopg installation."
|
||||
) from exc
|
||||
if legacy is not None:
|
||||
raise RuntimeError(
|
||||
"Legacy Lens tables exist. prisma db push would drop saved Lens data. "
|
||||
"Apply the shipped 20261001100000_rename_lens migration to this database schema before retrying. "
|
||||
"Deployments using migration history can upgrade without --use_prisma_db_push instead."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def spend_logs_is_partitioned() -> bool:
|
||||
"""True when the connected database's LiteLLM_SpendLogs is a
|
||||
|
|
@ -895,6 +925,7 @@ class ProxyExtrasDBManager:
|
|||
migrations_dir = ProxyExtrasDBManager._get_prisma_dir()
|
||||
|
||||
if not use_migrate:
|
||||
ProxyExtrasDBManager.raise_if_lens_rename_pending()
|
||||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
original_dir = os.getcwd()
|
||||
|
|
@ -1398,6 +1429,7 @@ class ProxyExtrasDBManager:
|
|||
if ProxyExtrasDBManager.spend_logs_is_partitioned():
|
||||
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
|
||||
# Use prisma db push with increased timeout
|
||||
ProxyExtrasDBManager.raise_if_lens_rename_pending()
|
||||
prisma_toolchain.run_prisma(
|
||||
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
|
||||
timeout=prisma_command_timeout(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ version = "0.4.103"
|
|||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"psycopg>=3.2,<4.0",
|
||||
"psycopg-binary>=3.2,<4.0",
|
||||
]
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [
|
||||
|
|
|
|||
|
|
@ -34666,52 +34666,6 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/engine/workers/register": {
|
||||
"post": {
|
||||
"operationId": "register_worker_engine_workers_register_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkerName"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkerCreated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Register Worker",
|
||||
"tags": [
|
||||
"mcp_discoverable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/guardrails/register": {
|
||||
"post": {
|
||||
"description": "Register a guardrail for onboarding (team submission).\n\nAccepts a guardrail config in the\n[Generic Guardrail API](https://docs.litellm.ai/docs/adding_provider/generic_guardrail_api) format.\nThe submission is stored with status `pending_review` until an admin approves it.",
|
||||
|
|
@ -34804,6 +34758,52 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/lens/workers/register": {
|
||||
"post": {
|
||||
"operationId": "register_worker_lens_workers_register_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkerName"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/WorkerCreated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Register Worker",
|
||||
"tags": [
|
||||
"mcp_discoverable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/register": {
|
||||
"post": {
|
||||
"operationId": "register_client_register_post",
|
||||
|
|
|
|||
|
|
@ -520,16 +520,16 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/v1/rag/ingest",
|
||||
"/rag/query",
|
||||
"/v1/rag/query",
|
||||
"/engine",
|
||||
"/engine/{engine_id}",
|
||||
"/engine/{engine_id}/runs",
|
||||
"/engine/{engine_id}/runs/{job_id}",
|
||||
"/engine/{engine_id}/executions/{execution_id}",
|
||||
"/engine/{engine_id}/cancel",
|
||||
"/engine/{engine_id}/findings/{finding_id}",
|
||||
"/engine/preview/sample",
|
||||
"/engine/workers/register",
|
||||
"/engine/workers/{worker_id}",
|
||||
"/lens",
|
||||
"/lens/{lens_id}",
|
||||
"/lens/{lens_id}/runs",
|
||||
"/lens/{lens_id}/runs/{job_id}",
|
||||
"/lens/{lens_id}/executions/{execution_id}",
|
||||
"/lens/{lens_id}/cancel",
|
||||
"/lens/{lens_id}/findings/{finding_id}",
|
||||
"/lens/preview/sample",
|
||||
"/lens/workers/register",
|
||||
"/lens/workers/{worker_id}",
|
||||
"/v1/traces",
|
||||
"/v1/traces/{trace_id}",
|
||||
"/v1/traces/{trace_id}/spans/{span_id}",
|
||||
|
|
|
|||
|
|
@ -953,6 +953,9 @@ class PrismaManager:
|
|||
verbose_proxy_logger.error("\x1b[1;31mLiteLLM: Failed to import proxy extras. Got %s\x1b[0m", e)
|
||||
return False
|
||||
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
|
||||
ProxyExtrasDBManager.raise_if_lens_rename_pending()
|
||||
PrismaManager._raise_if_partitioned_spend_logs()
|
||||
run_prisma(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ from pydantic import AwareDatetime, BaseModel, Field, TypeAdapter
|
|||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
|
||||
from litellm.proxy.engine.billing import validate_key
|
||||
from litellm.proxy.engine.models import (
|
||||
from litellm.proxy.lens.billing import validate_key
|
||||
from litellm.proxy.lens.models import (
|
||||
Claim,
|
||||
Engine,
|
||||
EngineList,
|
||||
EngineSettings,
|
||||
Execution,
|
||||
ExecutionContent,
|
||||
FindingDraft,
|
||||
FindingUpdate,
|
||||
Job,
|
||||
Lens,
|
||||
LensList,
|
||||
LensSettings,
|
||||
ModelRequest,
|
||||
ModelResult,
|
||||
Progress,
|
||||
|
|
@ -34,9 +34,9 @@ from litellm.proxy.engine.models import (
|
|||
Worker,
|
||||
WorkerCreated,
|
||||
)
|
||||
from litellm.proxy.engine.repository import EngineRepository, WriterDatabase
|
||||
from litellm.proxy.engine.sources import SourceReader, parse_execution
|
||||
from litellm.proxy.engine.state import (
|
||||
from litellm.proxy.lens.repository import LensRepository, WriterDatabase
|
||||
from litellm.proxy.lens.sources import SourceReader, parse_execution
|
||||
from litellm.proxy.lens.state import (
|
||||
can_access,
|
||||
claim_job,
|
||||
current_job,
|
||||
|
|
@ -46,17 +46,17 @@ from litellm.proxy.engine.state import (
|
|||
snapshot_finding,
|
||||
)
|
||||
|
||||
router: Final = APIRouter(prefix="/engine", tags=["Lens"]) # mutable-ok: FastAPI requires list
|
||||
router: Final = APIRouter(prefix="/lens", tags=["Lens"]) # mutable-ok: FastAPI requires list
|
||||
_bearer: Final = HTTPBearer()
|
||||
Auth: TypeAlias = Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]
|
||||
|
||||
|
||||
def repository() -> EngineRepository:
|
||||
def repository() -> LensRepository:
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(503, "Lens needs a connected Postgres database")
|
||||
return EngineRepository(WriterDatabase(writer_wrapper(prisma_client.db)))
|
||||
return LensRepository(WriterDatabase(writer_wrapper(prisma_client.db)))
|
||||
|
||||
|
||||
def source_reader() -> SourceReader:
|
||||
|
|
@ -73,11 +73,11 @@ def user_scope(auth: UserAPIKeyAuth, write: bool = False) -> Scope:
|
|||
raise HTTPException(403, "Lens requires proxy administrator access")
|
||||
|
||||
|
||||
async def get_engine(engine_id: str, scope: Scope) -> Engine:
|
||||
engine: Final = await repository().get(engine_id)
|
||||
if engine is None or not can_access(scope, engine.scope):
|
||||
async def get_lens(lens_id: str, scope: Scope) -> Lens:
|
||||
lens: Final = await repository().get(lens_id)
|
||||
if lens is None or not can_access(scope, lens.scope):
|
||||
raise HTTPException(404, "Lens not found")
|
||||
return engine
|
||||
return lens
|
||||
|
||||
|
||||
async def worker_auth(credentials: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)]) -> Worker:
|
||||
|
|
@ -90,9 +90,9 @@ async def worker_auth(credentials: Annotated[HTTPAuthorizationCredentials, Depen
|
|||
WorkerAuth: TypeAlias = Annotated[Worker, Depends(worker_auth)]
|
||||
|
||||
|
||||
async def assigned(engine_id: str, job_id: str, worker: Worker) -> tuple[Engine, Job]:
|
||||
engine: Final = await get_engine(engine_id, worker.scope)
|
||||
job: Final = current_job(engine)
|
||||
async def assigned(lens_id: str, job_id: str, worker: Worker) -> tuple[Lens, Job]:
|
||||
lens: Final = await get_lens(lens_id, worker.scope)
|
||||
job: Final = current_job(lens)
|
||||
if (
|
||||
job is None
|
||||
or job.id != job_id
|
||||
|
|
@ -102,16 +102,16 @@ async def assigned(engine_id: str, job_id: str, worker: Worker) -> tuple[Engine,
|
|||
or job.lease_until <= datetime.now(timezone.utc)
|
||||
):
|
||||
raise HTTPException(409, "This worker no longer owns the job")
|
||||
return engine, job
|
||||
return lens, job
|
||||
|
||||
|
||||
def required(engine: Engine | None) -> Engine:
|
||||
if engine is None:
|
||||
def required(lens: Lens | None) -> Lens:
|
||||
if lens is None:
|
||||
raise HTTPException(409, "Lens changed concurrently; retry the operation")
|
||||
return engine
|
||||
return lens
|
||||
|
||||
|
||||
def validate_selection(settings: EngineSettings) -> None:
|
||||
def validate_selection(settings: LensSettings) -> None:
|
||||
for identity in settings.execution_ids:
|
||||
try:
|
||||
source, _, _, _ = parse_execution(identity)
|
||||
|
|
@ -121,7 +121,7 @@ def validate_selection(settings: EngineSettings) -> None:
|
|||
raise HTTPException(422, "Choose execution IDs returned by the activity preview")
|
||||
|
||||
|
||||
def validate_model(settings: EngineSettings, auth: UserAPIKeyAuth) -> None:
|
||||
def validate_model(settings: LensSettings, auth: UserAPIKeyAuth) -> None:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
validate_selection(settings)
|
||||
|
|
@ -137,24 +137,24 @@ def validate_model(settings: EngineSettings, auth: UserAPIKeyAuth) -> None:
|
|||
raise HTTPException(403, "This key does not have access to the analysis model")
|
||||
|
||||
|
||||
@router.get("", response_model=EngineList)
|
||||
async def list_engines(auth: Auth) -> EngineList:
|
||||
@router.get("", response_model=LensList)
|
||||
async def list_lenses(auth: Auth) -> LensList:
|
||||
from litellm.proxy import tracing_endpoints
|
||||
|
||||
scope: Final = user_scope(auth)
|
||||
return EngineList(
|
||||
engines=tuple(e for e in await repository().engines() if can_access(scope, e.scope)),
|
||||
return LensList(
|
||||
lenses=tuple(e for e in await repository().lenses() if can_access(scope, e.scope)),
|
||||
workers=tuple(w for w in await repository().workers() if can_access(scope, w.scope)),
|
||||
tracing_enabled=tracing_endpoints.receiver is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=Engine)
|
||||
async def create_engine(settings: EngineSettings, auth: Auth) -> Engine:
|
||||
@router.post("", response_model=Lens)
|
||||
async def create_lens(settings: LensSettings, auth: Auth) -> Lens:
|
||||
scope: Final = user_scope(auth, write=True)
|
||||
validate_model(settings, auth)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
engine: Final = Engine(
|
||||
lens: Final = Lens(
|
||||
id=str(uuid4()),
|
||||
scope=scope,
|
||||
settings=settings,
|
||||
|
|
@ -162,16 +162,16 @@ async def create_engine(settings: EngineSettings, auth: Auth) -> Engine:
|
|||
next_run_at=now,
|
||||
budget_month=now.strftime("%Y-%m"),
|
||||
)
|
||||
return await repository().create(queue_job(engine, now, str(uuid4())))
|
||||
return await repository().create(queue_job(lens, now, str(uuid4())))
|
||||
|
||||
|
||||
@router.put("/{engine_id}", response_model=Engine)
|
||||
async def update_engine(engine_id: str, settings: EngineSettings, auth: Auth) -> Engine:
|
||||
await get_engine(engine_id, user_scope(auth, write=True))
|
||||
@router.put("/{lens_id}", response_model=Lens)
|
||||
async def update_lens(lens_id: str, settings: LensSettings, auth: Auth) -> Lens:
|
||||
await get_lens(lens_id, user_scope(auth, write=True))
|
||||
validate_model(settings, auth)
|
||||
return required(
|
||||
await repository().update(
|
||||
engine_id,
|
||||
lens_id,
|
||||
lambda e: e.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
|
|
@ -184,47 +184,47 @@ async def update_engine(engine_id: str, settings: EngineSettings, auth: Auth) ->
|
|||
)
|
||||
|
||||
|
||||
@router.post("/{engine_id}/runs", response_model=Engine)
|
||||
async def run_engine(engine_id: str, body: RunRequest, auth: Auth) -> Engine:
|
||||
await get_engine(engine_id, user_scope(auth, write=True))
|
||||
@router.post("/{lens_id}/runs", response_model=Lens)
|
||||
async def run_lens(lens_id: str, body: RunRequest, auth: Auth) -> Lens:
|
||||
await get_lens(lens_id, user_scope(auth, write=True))
|
||||
if body.settings is not None:
|
||||
validate_model(body.settings, auth)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
job_id: Final = str(uuid4())
|
||||
return required(
|
||||
await repository().update(engine_id, lambda e: queue_job(e, now, job_id, body.lookback_hours, body.settings))
|
||||
await repository().update(lens_id, lambda e: queue_job(e, now, job_id, body.lookback_hours, body.settings))
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{engine_id}", response_model=Engine)
|
||||
async def read_engine(engine_id: str, auth: Auth) -> Engine:
|
||||
return await get_engine(engine_id, user_scope(auth))
|
||||
@router.get("/{lens_id}", response_model=Lens)
|
||||
async def read_lens(lens_id: str, auth: Auth) -> Lens:
|
||||
return await get_lens(lens_id, user_scope(auth))
|
||||
|
||||
|
||||
@router.get("/{engine_id}/runs", response_model=tuple[Job, ...])
|
||||
async def list_runs(engine_id: str, auth: Auth, offset: int = Query(default=0, ge=0)) -> tuple[Job, ...]:
|
||||
await get_engine(engine_id, user_scope(auth))
|
||||
@router.get("/{lens_id}/runs", response_model=tuple[Job, ...])
|
||||
async def list_runs(lens_id: str, auth: Auth, offset: int = Query(default=0, ge=0)) -> tuple[Job, ...]:
|
||||
await get_lens(lens_id, user_scope(auth))
|
||||
return tuple(
|
||||
j.model_copy(update=MappingProxyType({"sample": None, "findings": None, "assessments": ()}))
|
||||
for j in await repository().jobs(engine_id, offset)
|
||||
for j in await repository().jobs(lens_id, offset)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{engine_id}/runs/{job_id}", response_model=Job)
|
||||
async def read_run(engine_id: str, job_id: str, auth: Auth) -> Job:
|
||||
await get_engine(engine_id, user_scope(auth))
|
||||
job: Final = await repository().job(engine_id, job_id)
|
||||
@router.get("/{lens_id}/runs/{job_id}", response_model=Job)
|
||||
async def read_run(lens_id: str, job_id: str, auth: Auth) -> Job:
|
||||
await get_lens(lens_id, user_scope(auth))
|
||||
job: Final = await repository().job(lens_id, job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "Investigation not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/{engine_id}/cancel", response_model=Engine)
|
||||
async def cancel_engine(engine_id: str, auth: Auth) -> Engine:
|
||||
await get_engine(engine_id, user_scope(auth, write=True))
|
||||
@router.post("/{lens_id}/cancel", response_model=Lens)
|
||||
async def cancel_lens(lens_id: str, auth: Auth) -> Lens:
|
||||
await get_lens(lens_id, user_scope(auth, write=True))
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
|
||||
def cancel(e: Engine) -> Engine:
|
||||
def cancel(e: Lens) -> Lens:
|
||||
job: Final = current_job(e)
|
||||
if job is None:
|
||||
return e
|
||||
|
|
@ -235,15 +235,15 @@ async def cancel_engine(engine_id: str, auth: Auth) -> Engine:
|
|||
update=MappingProxyType({"next_run_at": now + timedelta(minutes=e.settings.interval_minutes)})
|
||||
)
|
||||
|
||||
return required(await repository().update(engine_id, cancel))
|
||||
return required(await repository().update(lens_id, cancel))
|
||||
|
||||
|
||||
@router.patch("/{engine_id}/findings/{finding_id}", response_model=Engine)
|
||||
async def update_finding(engine_id: str, finding_id: str, body: FindingUpdate, auth: Auth) -> Engine:
|
||||
await get_engine(engine_id, user_scope(auth, write=True))
|
||||
@router.patch("/{lens_id}/findings/{finding_id}", response_model=Lens)
|
||||
async def update_finding(lens_id: str, finding_id: str, body: FindingUpdate, auth: Auth) -> Lens:
|
||||
await get_lens(lens_id, user_scope(auth, write=True))
|
||||
return required(
|
||||
await repository().update(
|
||||
engine_id,
|
||||
lens_id,
|
||||
lambda e: e.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
|
|
@ -260,7 +260,7 @@ async def update_finding(engine_id: str, finding_id: str, body: FindingUpdate, a
|
|||
class Preview(BaseModel):
|
||||
as_of: AwareDatetime | None = None
|
||||
offset: int = Field(default=0, ge=0)
|
||||
settings: EngineSettings
|
||||
settings: LensSettings
|
||||
lookback_hours: int = Field(default=24, ge=1, le=720)
|
||||
|
||||
|
||||
|
|
@ -335,7 +335,7 @@ async def claim(worker: WorkerAuth, protocol_version: int = 1) -> Claim | None:
|
|||
raise HTTPException(409, "Assign an analysis key to this worker in Lens setup")
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
await repository().heartbeat(worker.id, now.isoformat())
|
||||
for candidate in await repository().engines():
|
||||
for candidate in await repository().lenses():
|
||||
if not can_access(worker.scope, candidate.scope):
|
||||
continue
|
||||
if claimed := await claim_candidate(candidate, worker, now):
|
||||
|
|
@ -343,12 +343,12 @@ async def claim(worker: WorkerAuth, protocol_version: int = 1) -> Claim | None:
|
|||
return None
|
||||
|
||||
|
||||
@router.post("/worker/{engine_id}/{job_id}/progress", response_model=bool)
|
||||
async def progress(engine_id: str, job_id: str, body: Progress, worker: WorkerAuth) -> bool:
|
||||
await assigned(engine_id, job_id, worker)
|
||||
@router.post("/worker/{lens_id}/{job_id}/progress", response_model=bool)
|
||||
async def progress(lens_id: str, job_id: str, body: Progress, worker: WorkerAuth) -> bool:
|
||||
await assigned(lens_id, job_id, worker)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
|
||||
def renew(e: Engine) -> Engine:
|
||||
def renew(e: Lens) -> Lens:
|
||||
job: Final = current_job(e)
|
||||
if job is None or job.id != job_id or job.worker_id != worker.id:
|
||||
return e
|
||||
|
|
@ -361,21 +361,21 @@ async def progress(engine_id: str, job_id: str, body: Progress, worker: WorkerAu
|
|||
),
|
||||
)
|
||||
|
||||
required(await repository().update(engine_id, renew))
|
||||
required(await repository().update(lens_id, renew))
|
||||
await repository().heartbeat(worker.id, now.isoformat())
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/worker/{engine_id}/{job_id}/sample", response_model=Sample)
|
||||
async def sample(engine_id: str, job_id: str, worker: WorkerAuth) -> Sample:
|
||||
engine, job = await assigned(engine_id, job_id, worker)
|
||||
@router.get("/worker/{lens_id}/{job_id}/sample", response_model=Sample)
|
||||
async def sample(lens_id: str, job_id: str, worker: WorkerAuth) -> Sample:
|
||||
lens, job = await assigned(lens_id, job_id, worker)
|
||||
if job.sample is not None:
|
||||
return job.sample
|
||||
pages: list[Sample] = [] # mutable-ok: freeze selection after stable cursor traversal
|
||||
cursor = "" # rebind-ok: advance by immutable identity, never by shifting row positions
|
||||
while True:
|
||||
page = await source_reader().sample(
|
||||
engine.scope,
|
||||
lens.scope,
|
||||
job.settings,
|
||||
int(job.start.timestamp() * 1000),
|
||||
int(job.end.timestamp() * 1000),
|
||||
|
|
@ -390,7 +390,7 @@ async def sample(engine_id: str, job_id: str, worker: WorkerAuth) -> Sample:
|
|||
) # comprehension-ok: flatten query pages
|
||||
selected: Final = Sample(executions=executions, eligible=pages[0].eligible, selected=len(executions))
|
||||
|
||||
def freeze(e: Engine) -> Engine:
|
||||
def freeze(e: Lens) -> Lens:
|
||||
active: Final = current_job(e)
|
||||
if active is None or active.id != job_id or active.worker_id != worker.id:
|
||||
raise HTTPException(409, "Job was cancelled or reassigned")
|
||||
|
|
@ -400,45 +400,45 @@ async def sample(engine_id: str, job_id: str, worker: WorkerAuth) -> Sample:
|
|||
else e
|
||||
)
|
||||
|
||||
updated: Final = required(await repository().update(engine_id, freeze))
|
||||
updated: Final = required(await repository().update(lens_id, freeze))
|
||||
frozen: Final = next(j for j in updated.jobs if j.id == job_id).sample
|
||||
if frozen is None:
|
||||
raise HTTPException(409, "Could not freeze the sample")
|
||||
return frozen
|
||||
|
||||
|
||||
@router.get("/worker/{engine_id}/{job_id}/content", response_model=ExecutionContent)
|
||||
@router.get("/worker/{lens_id}/{job_id}/content", response_model=ExecutionContent)
|
||||
async def content(
|
||||
engine_id: str,
|
||||
lens_id: str,
|
||||
job_id: str,
|
||||
execution_id: str,
|
||||
worker: WorkerAuth,
|
||||
cursor: str = "",
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ExecutionContent:
|
||||
engine, job = await assigned(engine_id, job_id, worker)
|
||||
lens, job = await assigned(lens_id, job_id, worker)
|
||||
selected: Final = job.sample or Sample(executions=(), eligible=0)
|
||||
execution: Final = next((e for e in selected.executions if e.id == execution_id), None)
|
||||
if execution is None:
|
||||
raise HTTPException(404, "Execution is outside this job's sample")
|
||||
return await source_reader().content(engine.scope, execution, cursor, offset)
|
||||
return await source_reader().content(lens.scope, execution, cursor, offset)
|
||||
|
||||
|
||||
@router.post("/worker/{engine_id}/{job_id}/model", response_model=ModelResult)
|
||||
async def model(engine_id: str, job_id: str, body: ModelRequest, worker: WorkerAuth, request: Request) -> ModelResult:
|
||||
from litellm.proxy.engine.inference import analyze
|
||||
@router.post("/worker/{lens_id}/{job_id}/model", response_model=ModelResult)
|
||||
async def model(lens_id: str, job_id: str, body: ModelRequest, worker: WorkerAuth, request: Request) -> ModelResult:
|
||||
from litellm.proxy.lens.inference import analyze
|
||||
|
||||
engine, job = await assigned(engine_id, job_id, worker)
|
||||
return await analyze(repository(), engine, job, worker, body, request)
|
||||
lens, job = await assigned(lens_id, job_id, worker)
|
||||
return await analyze(repository(), lens, job, worker, body, request)
|
||||
|
||||
|
||||
@router.post("/worker/{engine_id}/{job_id}/result", response_model=Engine)
|
||||
async def result(engine_id: str, job_id: str, body: Result, worker: WorkerAuth) -> Engine:
|
||||
engine: Final = await get_engine(engine_id, worker.scope)
|
||||
old: Final = next((j for j in engine.jobs if j.id == job_id), None)
|
||||
@router.post("/worker/{lens_id}/{job_id}/result", response_model=Lens)
|
||||
async def result(lens_id: str, job_id: str, body: Result, worker: WorkerAuth) -> Lens:
|
||||
lens: Final = await get_lens(lens_id, worker.scope)
|
||||
old: Final = next((j for j in lens.jobs if j.id == job_id), None)
|
||||
if old and old.status in ("completed", "failed") and old.worker_id == worker.id:
|
||||
return engine
|
||||
_, job = await assigned(engine_id, job_id, worker)
|
||||
return lens
|
||||
_, job = await assigned(lens_id, job_id, worker)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
selected: Final = job.sample or Sample(executions=(), eligible=0)
|
||||
allowed: Final = frozenset(e.id for e in selected.executions)
|
||||
|
|
@ -455,9 +455,9 @@ async def result(engine_id: str, job_id: str, body: Result, worker: WorkerAuth)
|
|||
raise HTTPException(422, "Finding references evidence outside the job")
|
||||
|
||||
for finding in body.findings:
|
||||
await validate_finding(engine, selected, finding)
|
||||
await validate_finding(lens, selected, finding)
|
||||
|
||||
def finish(e: Engine) -> Engine:
|
||||
def finish(e: Lens) -> Lens:
|
||||
active: Final = current_job(e)
|
||||
if active is None or active.id != job_id or active.worker_id != worker.id:
|
||||
return e
|
||||
|
|
@ -488,29 +488,29 @@ async def result(engine_id: str, job_id: str, body: Result, worker: WorkerAuth)
|
|||
)
|
||||
)
|
||||
|
||||
return required(await repository().update(engine_id, finish))
|
||||
return required(await repository().update(lens_id, finish))
|
||||
|
||||
|
||||
def merge_results(engine: Engine, result: Result, revision: int, now: datetime) -> Engine:
|
||||
def merge_one(current: Engine, draft: FindingDraft) -> Engine:
|
||||
def merge_results(lens: Lens, result: Result, revision: int, now: datetime) -> Lens:
|
||||
def merge_one(current: Lens, draft: FindingDraft) -> Lens:
|
||||
finding: Final = merge_finding(current, draft, revision, now)
|
||||
return current.model_copy(
|
||||
update=MappingProxyType({"findings": (finding, *(f for f in current.findings if f.id != finding.id))})
|
||||
)
|
||||
|
||||
return reduce(merge_one, result.findings, engine)
|
||||
return reduce(merge_one, result.findings, lens)
|
||||
|
||||
|
||||
@router.post("/worker/{engine_id}/{job_id}/heartbeat", response_model=bool)
|
||||
async def heartbeat(engine_id: str, job_id: str, worker: WorkerAuth) -> bool:
|
||||
_, job = await assigned(engine_id, job_id, worker)
|
||||
return await progress(engine_id, job_id, Progress(stage=job.stage, coverage=job.coverage), worker)
|
||||
@router.post("/worker/{lens_id}/{job_id}/heartbeat", response_model=bool)
|
||||
async def heartbeat(lens_id: str, job_id: str, worker: WorkerAuth) -> bool:
|
||||
_, job = await assigned(lens_id, job_id, worker)
|
||||
return await progress(lens_id, job_id, Progress(stage=job.stage, coverage=job.coverage), worker)
|
||||
|
||||
|
||||
async def claim_candidate(candidate: Engine, worker: Worker, now: datetime) -> Claim | None:
|
||||
async def claim_candidate(candidate: Lens, worker: Worker, now: datetime) -> Claim | None:
|
||||
job_id: Final = str(uuid4())
|
||||
|
||||
def schedule(e: Engine) -> Engine:
|
||||
def schedule(e: Lens) -> Lens:
|
||||
scheduled: Final = queue_job(e, now, job_id) if e.settings.enabled and e.next_run_at <= now else e
|
||||
return claim_job(scheduled, worker, now)
|
||||
|
||||
|
|
@ -519,31 +519,31 @@ async def claim_candidate(candidate: Engine, worker: Worker, now: datetime) -> C
|
|||
return None
|
||||
job: Final = current_job(updated)
|
||||
if job and job.worker_id == worker.id and job.status == "running" and job != current_job(candidate):
|
||||
return Claim(engine_id=updated.id, job=job, findings=updated.findings)
|
||||
return Claim(lens_id=updated.id, job=job, findings=updated.findings)
|
||||
return None
|
||||
|
||||
|
||||
async def validate_finding(engine: Engine, selected: Sample, finding: FindingDraft) -> None:
|
||||
previous: Final = next((f for f in engine.findings if f.id == finding.existing_finding_id), None)
|
||||
async def validate_finding(lens: Lens, selected: Sample, finding: FindingDraft) -> None:
|
||||
previous: Final = next((f for f in lens.findings if f.id == finding.existing_finding_id), None)
|
||||
if finding.existing_finding_id and (previous is None or previous.check_id != finding.check_id):
|
||||
raise HTTPException(422, "Existing finding must belong to the same check")
|
||||
for evidence in finding.evidence:
|
||||
if not await source_reader().verify_evidence(
|
||||
engine.scope, next(e for e in selected.executions if e.id == evidence.execution_id), evidence
|
||||
lens.scope, next(e for e in selected.executions if e.id == evidence.execution_id), evidence
|
||||
):
|
||||
raise HTTPException(422, "Evidence quote does not match stored content")
|
||||
|
||||
|
||||
@router.get("/{engine_id}/executions/{execution_id}", response_model=ExecutionContent)
|
||||
@router.get("/{lens_id}/executions/{execution_id}", response_model=ExecutionContent)
|
||||
async def evidence_content(
|
||||
engine_id: str, execution_id: str, auth: Auth, cursor: str = "", offset: int = Query(default=0, ge=0)
|
||||
lens_id: str, execution_id: str, auth: Auth, cursor: str = "", offset: int = Query(default=0, ge=0)
|
||||
) -> ExecutionContent:
|
||||
engine: Final = await get_engine(engine_id, user_scope(auth))
|
||||
lens: Final = await get_lens(lens_id, user_scope(auth))
|
||||
try:
|
||||
source, team, trace_id, trace_ref = parse_execution(execution_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Execution not found")
|
||||
if source not in ("traces", "requests") or (not engine.scope.all_teams and team != engine.scope.team_id):
|
||||
if source not in ("traces", "requests") or (not lens.scope.all_teams and team != lens.scope.team_id):
|
||||
raise HTTPException(404, "Execution not found")
|
||||
execution: Final = Execution(
|
||||
id=execution_id,
|
||||
|
|
@ -556,4 +556,4 @@ async def evidence_content(
|
|||
span_count=1,
|
||||
root_seen=source == "requests",
|
||||
)
|
||||
return await source_reader().content(engine.scope, execution, cursor, offset)
|
||||
return await source_reader().content(lens.scope, execution, cursor, offset)
|
||||
|
|
@ -8,10 +8,10 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||
import litellm
|
||||
from litellm.integrations.clickhouse.context import lens_analysis
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import inherit_message_logging_privacy
|
||||
from litellm.proxy.engine.billing import complete, validate_key
|
||||
from litellm.proxy.engine.models import Engine, Job, ModelRequest, ModelResult, Worker
|
||||
from litellm.proxy.engine.repository import EngineRepository
|
||||
from litellm.proxy.engine.state import current_job, renew_budget, replace_job
|
||||
from litellm.proxy.lens.billing import complete, validate_key
|
||||
from litellm.proxy.lens.models import Job, Lens, ModelRequest, ModelResult, Worker
|
||||
from litellm.proxy.lens.repository import LensRepository
|
||||
from litellm.proxy.lens.state import current_job, renew_budget, replace_job
|
||||
from litellm.types.utils import CostPerToken, ModelResponse
|
||||
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ def quote(deployments: tuple[Deployment, ...], prompt: str) -> float:
|
|||
|
||||
|
||||
async def analyze(
|
||||
repo: EngineRepository, engine: Engine, job: Job, worker: Worker, body: ModelRequest, request: Request
|
||||
repo: LensRepository, lens: Lens, job: Job, worker: Worker, body: ModelRequest, request: Request
|
||||
) -> ModelResult:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ async def analyze(
|
|||
estimate: Final = quote(deployments, body.prompt)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
|
||||
def reserve(e: Engine) -> Engine:
|
||||
def reserve(e: Lens) -> Lens:
|
||||
current: Final = renew_budget(e, now)
|
||||
active: Final = current_job(current)
|
||||
if (
|
||||
|
|
@ -124,7 +124,7 @@ async def analyze(
|
|||
).model_copy(update=MappingProxyType({"spent": current.spent + estimate}))
|
||||
|
||||
async def reserve_budget() -> None:
|
||||
if await repo.update(engine.id, reserve) is None:
|
||||
if await repo.update(lens.id, reserve) is None:
|
||||
raise HTTPException(409, "Could not reserve analysis budget")
|
||||
|
||||
data: Final[dict[str, object]] = { # mutable-ok: proxy processing enriches request data
|
||||
|
|
@ -140,8 +140,8 @@ async def analyze(
|
|||
"disable_fallbacks": True,
|
||||
"response_format": {"type": "json_object"}, # mutable-ok: provider response-format JSON
|
||||
"metadata": { # mutable-ok: request processing enriches metadata
|
||||
"tags": ["litellm-engine"], # mutable-ok: logging callbacks require a list
|
||||
"lens_id": engine.id,
|
||||
"tags": ["litellm-lens"], # mutable-ok: logging callbacks require a list
|
||||
"lens_id": lens.id,
|
||||
"lens_run_id": job.id,
|
||||
"lens_worker_id": worker.id,
|
||||
"user_api_key_team_id": team_id,
|
||||
|
|
@ -153,7 +153,7 @@ async def analyze(
|
|||
parsed: Final = Completion.model_validate_json(response.model_dump_json())
|
||||
cost: Final = billed_cost if billed_cost is not None else completion_charge(deployments, response, estimate)
|
||||
|
||||
def settle(e: Engine) -> Engine:
|
||||
def settle(e: Lens) -> Lens:
|
||||
charged: Final = next((j for j in e.jobs if j.id == job.id), None)
|
||||
adjusted: Final = (
|
||||
e.model_copy(update=MappingProxyType({"spent": max(0, e.spent - estimate + cost)}))
|
||||
|
|
@ -168,7 +168,7 @@ async def analyze(
|
|||
else adjusted
|
||||
)
|
||||
|
||||
await repo.update(engine.id, settle)
|
||||
await repo.update(lens.id, settle)
|
||||
return ModelResult(content=parsed.choices[0].message.content or "{}", cost=cost)
|
||||
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ class Check(Record):
|
|||
enabled: bool = True
|
||||
|
||||
|
||||
class EngineSettings(Record):
|
||||
class LensSettings(Record):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
context: str = Field(default="", max_length=6000)
|
||||
source: Literal["traces", "requests", "both"] = "traces"
|
||||
|
|
@ -44,7 +44,7 @@ class EngineSettings(Record):
|
|||
monthly_budget: float = Field(default=20, gt=0, le=100000, allow_inf_nan=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_checks(self) -> "EngineSettings":
|
||||
def unique_checks(self) -> "LensSettings":
|
||||
if len(frozenset(c.id for c in self.checks)) != len(self.checks):
|
||||
raise ValueError("Each check must have a unique ID")
|
||||
if not self.context.strip() and not any(c.enabled for c in self.checks):
|
||||
|
|
@ -163,7 +163,7 @@ class Job(Record):
|
|||
created_at: datetime
|
||||
start: datetime
|
||||
end: datetime
|
||||
settings: EngineSettings
|
||||
settings: LensSettings
|
||||
revision: int
|
||||
worker_id: str | None = None
|
||||
lease_until: datetime | None = None
|
||||
|
|
@ -177,10 +177,10 @@ class Job(Record):
|
|||
assessments: tuple[RunAssessment, ...] = ()
|
||||
|
||||
|
||||
class Engine(Record):
|
||||
class Lens(Record):
|
||||
id: str
|
||||
scope: Scope
|
||||
settings: EngineSettings
|
||||
settings: LensSettings
|
||||
revision: int = 1
|
||||
version: int = 0
|
||||
created_at: datetime
|
||||
|
|
@ -206,14 +206,14 @@ class WorkerCreated(Record):
|
|||
token: str
|
||||
|
||||
|
||||
class EngineList(Record):
|
||||
engines: tuple[Engine, ...]
|
||||
class LensList(Record):
|
||||
lenses: tuple[Lens, ...]
|
||||
workers: tuple[Worker, ...]
|
||||
tracing_enabled: bool
|
||||
|
||||
|
||||
class RunRequest(Record):
|
||||
settings: EngineSettings | None = None
|
||||
settings: LensSettings | None = None
|
||||
lookback_hours: int | None = Field(default=None, ge=1, le=720)
|
||||
|
||||
|
||||
|
|
@ -223,7 +223,7 @@ class FindingUpdate(Record):
|
|||
|
||||
|
||||
class Claim(Record):
|
||||
engine_id: str
|
||||
lens_id: str
|
||||
job: Job
|
||||
findings: tuple[Finding, ...]
|
||||
|
||||
|
|
@ -5,7 +5,7 @@ from typing import Final, Protocol
|
|||
from pydantic import BaseModel, JsonValue, TypeAdapter
|
||||
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
from litellm.proxy.engine.models import Engine, Job, Worker
|
||||
from litellm.proxy.lens.models import Job, Lens, Worker
|
||||
|
||||
|
||||
class Database(Protocol):
|
||||
|
|
@ -20,44 +20,44 @@ class Row(BaseModel):
|
|||
_ROWS: Final = TypeAdapter(tuple[Row, ...])
|
||||
|
||||
|
||||
class EngineRepository:
|
||||
class LensRepository:
|
||||
def __init__(self, db: Database) -> None:
|
||||
self.db: Final = db
|
||||
|
||||
async def engines(self) -> tuple[Engine, ...]:
|
||||
rows: Final = _ROWS.validate_python(await self.db.query_raw('SELECT data FROM "LiteLLM_Engine" ORDER BY id'))
|
||||
return tuple(Engine.model_validate(row.data) for row in rows)
|
||||
async def lenses(self) -> tuple[Lens, ...]:
|
||||
rows: Final = _ROWS.validate_python(await self.db.query_raw('SELECT data FROM "LiteLLM_Lens" ORDER BY id'))
|
||||
return tuple(Lens.model_validate(row.data) for row in rows)
|
||||
|
||||
async def get(self, engine_id: str) -> Engine | None:
|
||||
async def get(self, lens_id: str) -> Lens | None:
|
||||
rows: Final = _ROWS.validate_python(
|
||||
await self.db.query_raw(
|
||||
'SELECT data FROM "LiteLLM_Engine" WHERE id=$1',
|
||||
engine_id,
|
||||
'SELECT data FROM "LiteLLM_Lens" WHERE id=$1',
|
||||
lens_id,
|
||||
)
|
||||
)
|
||||
return Engine.model_validate(rows[0].data) if rows else None
|
||||
return Lens.model_validate(rows[0].data) if rows else None
|
||||
|
||||
async def create(self, engine: Engine) -> Engine:
|
||||
async def create(self, lens: Lens) -> Lens:
|
||||
await self.db.execute_raw(
|
||||
'INSERT INTO "LiteLLM_Engine" (id, version, data) VALUES ($1,0,$2::jsonb)',
|
||||
engine.id,
|
||||
engine.model_dump_json(),
|
||||
'INSERT INTO "LiteLLM_Lens" (id, version, data) VALUES ($1,0,$2::jsonb)',
|
||||
lens.id,
|
||||
lens.model_dump_json(),
|
||||
)
|
||||
return engine
|
||||
return lens
|
||||
|
||||
async def update(
|
||||
self, engine_id: str, transform: Callable[[Engine], Engine], attempts: int = 8, *, changed_only: bool = False
|
||||
) -> Engine | None:
|
||||
self, lens_id: str, transform: Callable[[Lens], Lens], attempts: int = 8, *, changed_only: bool = False
|
||||
) -> Lens | None:
|
||||
for _ in range(attempts):
|
||||
completed, updated = await self._try_update(engine_id, transform, changed_only)
|
||||
completed, updated = await self._try_update(lens_id, transform, changed_only)
|
||||
if completed:
|
||||
return updated
|
||||
return None
|
||||
|
||||
async def _try_update(
|
||||
self, engine_id: str, transform: Callable[[Engine], Engine], changed_only: bool
|
||||
) -> tuple[bool, Engine | None]:
|
||||
previous: Final = await self.get(engine_id)
|
||||
self, lens_id: str, transform: Callable[[Lens], Lens], changed_only: bool
|
||||
) -> tuple[bool, Lens | None]:
|
||||
previous: Final = await self.get(lens_id)
|
||||
if previous is None:
|
||||
return True, None
|
||||
candidate: Final = transform(previous)
|
||||
|
|
@ -67,12 +67,12 @@ class EngineRepository:
|
|||
rows: Final = _ROWS.validate_python(
|
||||
await self.db.query_raw(
|
||||
"""WITH previous AS MATERIALIZED (
|
||||
SELECT data FROM "LiteLLM_Engine" WHERE id=$2 AND version=$3 FOR UPDATE
|
||||
SELECT data FROM "LiteLLM_Lens" WHERE id=$2 AND version=$3 FOR UPDATE
|
||||
), updated AS (
|
||||
UPDATE "LiteLLM_Engine" SET data=$1::jsonb, version=version+1
|
||||
UPDATE "LiteLLM_Lens" SET data=$1::jsonb, version=version+1
|
||||
WHERE id=$2 AND version=$3 AND EXISTS (SELECT 1 FROM previous) RETURNING id
|
||||
)
|
||||
, archived AS (INSERT INTO "LiteLLM_EngineRun" (id, engine_id, created_at, data)
|
||||
, archived AS (INSERT INTO "LiteLLM_LensRun" (id, lens_id, created_at, data)
|
||||
SELECT job->>'id', $2, (job->>'created_at')::timestamp, job
|
||||
FROM previous, jsonb_array_elements(previous.data->'jobs') AS job
|
||||
WHERE EXISTS (SELECT 1 FROM updated)
|
||||
|
|
@ -81,46 +81,46 @@ class EngineRepository:
|
|||
ON CONFLICT (id) DO NOTHING)
|
||||
SELECT to_jsonb(count(*)) AS data FROM updated""",
|
||||
updated.model_dump_json(),
|
||||
engine_id,
|
||||
lens_id,
|
||||
previous.version,
|
||||
)
|
||||
)
|
||||
return bool(rows and rows[0].data == 1), updated
|
||||
|
||||
async def jobs(self, engine_id: str, offset: int = 0) -> tuple[Job, ...]:
|
||||
async def jobs(self, lens_id: str, offset: int = 0) -> tuple[Job, ...]:
|
||||
rows: Final = _ROWS.validate_python(
|
||||
await self.db.query_raw(
|
||||
"""SELECT data FROM (
|
||||
SELECT data FROM "LiteLLM_EngineRun" WHERE engine_id=$1
|
||||
SELECT data FROM "LiteLLM_LensRun" WHERE lens_id=$1
|
||||
UNION ALL
|
||||
SELECT jsonb_array_elements(data->'jobs') AS data FROM "LiteLLM_Engine" WHERE id=$1
|
||||
SELECT jsonb_array_elements(data->'jobs') AS data FROM "LiteLLM_Lens" WHERE id=$1
|
||||
) AS jobs ORDER BY data->>'created_at' DESC, data->>'id' DESC LIMIT 50 OFFSET $2""",
|
||||
engine_id,
|
||||
lens_id,
|
||||
offset,
|
||||
)
|
||||
)
|
||||
return tuple(Job.model_validate(row.data) for row in rows)
|
||||
|
||||
async def job(self, engine_id: str, job_id: str) -> Job | None:
|
||||
async def job(self, lens_id: str, job_id: str) -> Job | None:
|
||||
rows: Final = _ROWS.validate_python(
|
||||
await self.db.query_raw(
|
||||
"""SELECT data FROM "LiteLLM_EngineRun" WHERE engine_id=$1 AND id=$2
|
||||
UNION ALL SELECT job AS data FROM "LiteLLM_Engine", jsonb_array_elements(data->'jobs') AS job
|
||||
"""SELECT data FROM "LiteLLM_LensRun" WHERE lens_id=$1 AND id=$2
|
||||
UNION ALL SELECT job AS data FROM "LiteLLM_Lens", jsonb_array_elements(data->'jobs') AS job
|
||||
WHERE id=$1 AND job->>'id'=$2 LIMIT 1""",
|
||||
engine_id,
|
||||
lens_id,
|
||||
job_id,
|
||||
)
|
||||
)
|
||||
return Job.model_validate(rows[0].data) if rows else None
|
||||
|
||||
async def workers(self) -> tuple[Worker, ...]:
|
||||
rows: Final = _ROWS.validate_python(await self.db.query_raw('SELECT data FROM "LiteLLM_EngineWorker"'))
|
||||
rows: Final = _ROWS.validate_python(await self.db.query_raw('SELECT data FROM "LiteLLM_LensWorker"'))
|
||||
return tuple(Worker.model_validate(row.data) for row in rows)
|
||||
|
||||
async def worker(self, token_hash: str) -> Worker | None:
|
||||
rows: Final = _ROWS.validate_python(
|
||||
await self.db.query_raw(
|
||||
'SELECT data FROM "LiteLLM_EngineWorker" WHERE token_hash=$1',
|
||||
'SELECT data FROM "LiteLLM_LensWorker" WHERE token_hash=$1',
|
||||
token_hash,
|
||||
)
|
||||
)
|
||||
|
|
@ -129,20 +129,20 @@ class EngineRepository:
|
|||
async def save_worker(self, worker: Worker, token_hash: str | None = None) -> None:
|
||||
if token_hash is not None:
|
||||
await self.db.execute_raw(
|
||||
'INSERT INTO "LiteLLM_EngineWorker" (id,token_hash,data) VALUES ($1,$2,$3::jsonb)',
|
||||
'INSERT INTO "LiteLLM_LensWorker" (id,token_hash,data) VALUES ($1,$2,$3::jsonb)',
|
||||
worker.id,
|
||||
token_hash,
|
||||
worker.model_dump_json(),
|
||||
)
|
||||
return
|
||||
await self.db.execute_raw(
|
||||
'UPDATE "LiteLLM_EngineWorker" SET data=$1::jsonb WHERE id=$2', worker.model_dump_json(), worker.id
|
||||
'UPDATE "LiteLLM_LensWorker" SET data=$1::jsonb WHERE id=$2', worker.model_dump_json(), worker.id
|
||||
)
|
||||
|
||||
async def set_worker_billing(self, worker_id: str, key_id: str) -> Worker | None:
|
||||
rows: Final = _ROWS.validate_python(
|
||||
await self.db.query_raw(
|
||||
"""UPDATE "LiteLLM_EngineWorker"
|
||||
"""UPDATE "LiteLLM_LensWorker"
|
||||
SET data=jsonb_set(data, '{analysis_key_id}', to_jsonb($1::text))
|
||||
WHERE id=$2 AND COALESCE((data->>'revoked')::boolean, false)=false RETURNING data""",
|
||||
key_id,
|
||||
|
|
@ -153,13 +153,13 @@ class EngineRepository:
|
|||
|
||||
async def revoke_worker(self, worker_id: str) -> None:
|
||||
await self.db.execute_raw(
|
||||
"""UPDATE "LiteLLM_EngineWorker" SET data=jsonb_set(data, '{revoked}', 'true') WHERE id=$1""",
|
||||
"""UPDATE "LiteLLM_LensWorker" SET data=jsonb_set(data, '{revoked}', 'true') WHERE id=$1""",
|
||||
worker_id,
|
||||
)
|
||||
|
||||
async def heartbeat(self, worker_id: str, now: str) -> None:
|
||||
await self.db.execute_raw(
|
||||
"""UPDATE "LiteLLM_EngineWorker" SET data=jsonb_set(data, '{last_seen}', to_jsonb($1::text)) WHERE id=$2""",
|
||||
"""UPDATE "LiteLLM_LensWorker" SET data=jsonb_set(data, '{last_seen}', to_jsonb($1::text)) WHERE id=$2""",
|
||||
now,
|
||||
worker_id,
|
||||
)
|
||||
|
|
@ -6,11 +6,11 @@ from typing import Final, Literal, Protocol
|
|||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
from litellm.proxy.engine.models import (
|
||||
EngineSettings,
|
||||
from litellm.proxy.lens.models import (
|
||||
Evidence,
|
||||
Execution,
|
||||
ExecutionContent,
|
||||
LensSettings,
|
||||
MetadataFilter,
|
||||
Sample,
|
||||
Scope,
|
||||
|
|
@ -93,7 +93,7 @@ class SourceReader:
|
|||
async def sample(
|
||||
self,
|
||||
scope: Scope,
|
||||
settings: EngineSettings,
|
||||
settings: LensSettings,
|
||||
start: int,
|
||||
end: int,
|
||||
offset: int = 0,
|
||||
|
|
@ -3,7 +3,7 @@ from datetime import datetime, timedelta
|
|||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.proxy.engine.models import Engine, EngineSettings, Finding, FindingDraft, Job, Scope, Worker
|
||||
from litellm.proxy.lens.models import Finding, FindingDraft, Job, Lens, LensSettings, Scope, Worker
|
||||
|
||||
|
||||
def can_access(viewer: Scope, target: Scope) -> bool:
|
||||
|
|
@ -14,46 +14,46 @@ def can_access(viewer: Scope, target: Scope) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def current_job(engine: Engine) -> Job | None:
|
||||
return next((job for job in engine.jobs if job.status in ("queued", "running")), None)
|
||||
def current_job(lens: Lens) -> Job | None:
|
||||
return next((job for job in lens.jobs if job.status in ("queued", "running")), None)
|
||||
|
||||
|
||||
def replace_job(engine: Engine, job: Job) -> Engine:
|
||||
return engine.model_copy(
|
||||
update=MappingProxyType({"jobs": tuple(job if old.id == job.id else old for old in engine.jobs)})
|
||||
def replace_job(lens: Lens, job: Job) -> Lens:
|
||||
return lens.model_copy(
|
||||
update=MappingProxyType({"jobs": tuple(job if old.id == job.id else old for old in lens.jobs)})
|
||||
)
|
||||
|
||||
|
||||
def queue_job(
|
||||
engine: Engine,
|
||||
lens: Lens,
|
||||
now: datetime,
|
||||
job_id: str,
|
||||
lookback_hours: int | None = None,
|
||||
settings: EngineSettings | None = None,
|
||||
) -> Engine:
|
||||
if current_job(engine):
|
||||
return engine
|
||||
selected: Final = settings or engine.settings
|
||||
settings: LensSettings | None = None,
|
||||
) -> Lens:
|
||||
if current_job(lens):
|
||||
return lens
|
||||
selected: Final = settings or lens.settings
|
||||
job: Final = Job(
|
||||
id=job_id,
|
||||
created_at=now,
|
||||
start=now - timedelta(hours=lookback_hours if lookback_hours is not None else selected.lookback_hours),
|
||||
end=now - timedelta(minutes=2),
|
||||
settings=selected,
|
||||
revision=engine.revision,
|
||||
revision=lens.revision,
|
||||
)
|
||||
return engine.model_copy(update=MappingProxyType({"jobs": (job,)}))
|
||||
return lens.model_copy(update=MappingProxyType({"jobs": (job,)}))
|
||||
|
||||
|
||||
def claim_job(engine: Engine, worker: Worker, now: datetime) -> Engine:
|
||||
job: Final = current_job(engine)
|
||||
if job is None or not can_access(worker.scope, engine.scope):
|
||||
return engine
|
||||
def claim_job(lens: Lens, worker: Worker, now: datetime) -> Lens:
|
||||
job: Final = current_job(lens)
|
||||
if job is None or not can_access(worker.scope, lens.scope):
|
||||
return lens
|
||||
if job.status == "running" and job.lease_until is not None and job.lease_until > now:
|
||||
return engine
|
||||
return lens
|
||||
if job.attempts >= 3:
|
||||
return replace_job(
|
||||
engine,
|
||||
lens,
|
||||
job.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
|
|
@ -64,11 +64,9 @@ def claim_job(engine: Engine, worker: Worker, now: datetime) -> Engine:
|
|||
}
|
||||
)
|
||||
),
|
||||
).model_copy(
|
||||
update=MappingProxyType({"next_run_at": now + timedelta(minutes=engine.settings.interval_minutes)})
|
||||
)
|
||||
).model_copy(update=MappingProxyType({"next_run_at": now + timedelta(minutes=lens.settings.interval_minutes)}))
|
||||
return replace_job(
|
||||
engine,
|
||||
lens,
|
||||
job.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
|
|
@ -83,23 +81,23 @@ def claim_job(engine: Engine, worker: Worker, now: datetime) -> Engine:
|
|||
)
|
||||
|
||||
|
||||
def renew_budget(engine: Engine, now: datetime) -> Engine:
|
||||
def renew_budget(lens: Lens, now: datetime) -> Lens:
|
||||
month: Final = now.strftime("%Y-%m")
|
||||
if engine.budget_month == month:
|
||||
return engine
|
||||
return engine.model_copy(update=MappingProxyType({"budget_month": month, "spent": 0}))
|
||||
if lens.budget_month == month:
|
||||
return lens
|
||||
return lens.model_copy(update=MappingProxyType({"budget_month": month, "spent": 0}))
|
||||
|
||||
|
||||
def merge_finding(engine: Engine, draft: FindingDraft, revision: int, now: datetime) -> Finding:
|
||||
legacy_identity: Final = hashlib.sha256(f"{engine.id}:{draft.check_id}:{draft.title.lower()}".encode()).hexdigest()[
|
||||
def merge_finding(lens: Lens, draft: FindingDraft, revision: int, now: datetime) -> Finding:
|
||||
legacy_identity: Final = hashlib.sha256(f"{lens.id}:{draft.check_id}:{draft.title.lower()}".encode()).hexdigest()[
|
||||
:24
|
||||
]
|
||||
identity: Final = hashlib.sha256(
|
||||
f"{engine.id}:{draft.check_id}:{draft.kind}:{draft.title.lower()}".encode()
|
||||
f"{lens.id}:{draft.check_id}:{draft.kind}:{draft.title.lower()}".encode()
|
||||
).hexdigest()[:24]
|
||||
identities: Final = (draft.existing_finding_id, identity, legacy_identity)
|
||||
previous: Final = next(
|
||||
(f for f in engine.findings if f.id in identities and f.kind == draft.kind and f.check_id == draft.check_id),
|
||||
(f for f in lens.findings if f.id in identities and f.kind == draft.kind and f.check_id == draft.check_id),
|
||||
None,
|
||||
)
|
||||
occurrences: Final = tuple(sorted(frozenset(e.execution_id for e in draft.evidence if e.role == "support")))
|
||||
|
|
@ -137,8 +135,8 @@ def merge_finding(engine: Engine, draft: FindingDraft, revision: int, now: datet
|
|||
)
|
||||
|
||||
|
||||
def snapshot_finding(engine: Engine, draft: FindingDraft, revision: int, now: datetime) -> Finding:
|
||||
merged: Final = merge_finding(engine, draft, revision, now)
|
||||
def snapshot_finding(lens: Lens, draft: FindingDraft, revision: int, now: datetime) -> Finding:
|
||||
merged: Final = merge_finding(lens, draft, revision, now)
|
||||
return Finding.model_validate(
|
||||
MappingProxyType(
|
||||
{
|
||||
|
|
@ -12,10 +12,10 @@ import httpx
|
|||
from .analysis import analyze_sample
|
||||
from .models import Claim, Coverage, ExecutionContent, ModelRequest, ModelResult, Progress, Result, Sample
|
||||
|
||||
logger: Final = logging.getLogger("litellm.engine.worker")
|
||||
logger: Final = logging.getLogger("litellm.lens.worker")
|
||||
|
||||
|
||||
class EngineWorker:
|
||||
class LensWorker:
|
||||
def __init__(self, client: httpx.AsyncClient, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep) -> None:
|
||||
self.client: Final = client
|
||||
self.sleep: Final = sleep
|
||||
|
|
@ -38,14 +38,12 @@ class EngineWorker:
|
|||
return await self.model_request(path, body, attempt + 1)
|
||||
|
||||
async def run_once(self) -> bool:
|
||||
response: Final = await self.client.post(
|
||||
"/engine/worker/claim", params=MappingProxyType({"protocol_version": 2})
|
||||
)
|
||||
response: Final = await self.client.post("/lens/worker/claim", params=MappingProxyType({"protocol_version": 2}))
|
||||
response.raise_for_status()
|
||||
if response.json() is None:
|
||||
return False
|
||||
claim: Final = Claim.model_validate(response.json())
|
||||
prefix: Final = f"/engine/worker/{claim.engine_id}/{claim.job.id}"
|
||||
prefix: Final = f"/lens/worker/{claim.lens_id}/{claim.job.id}"
|
||||
|
||||
async def model(body: ModelRequest) -> ModelResult:
|
||||
return await self.model_request(prefix + "/model", body)
|
||||
|
|
@ -111,7 +109,7 @@ async def main() -> None:
|
|||
async with httpx.AsyncClient(
|
||||
base_url=url, headers=MappingProxyType({"Authorization": f"Bearer {token}"}), timeout=180
|
||||
) as client:
|
||||
worker: Final = EngineWorker(client)
|
||||
worker: Final = LensWorker(client)
|
||||
while True:
|
||||
try:
|
||||
await worker.run_once()
|
||||
|
|
@ -541,7 +541,6 @@ from litellm.proxy.discovery_endpoints import (
|
|||
agent_skills_discovery_router,
|
||||
ui_discovery_endpoints_router,
|
||||
)
|
||||
from litellm.proxy.engine.endpoints import router as engine_router
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import set_fine_tuning_config
|
||||
from litellm.proxy.google_endpoints.endpoints import router as google_router
|
||||
|
|
@ -565,6 +564,7 @@ from litellm.proxy.hooks.prompt_injection_detection import (
|
|||
)
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger, run_spend_event
|
||||
from litellm.proxy.image_endpoints.endpoints import router as image_router
|
||||
from litellm.proxy.lens.endpoints import router as lens_router
|
||||
from litellm.proxy.list_api.common import (
|
||||
ManagementProblem,
|
||||
problem_response,
|
||||
|
|
@ -19988,7 +19988,7 @@ app.include_router(auto_router_management_router)
|
|||
app.include_router(tag_management_router)
|
||||
app.include_router(workflow_management_router)
|
||||
app.include_router(memory_router)
|
||||
app.include_router(engine_router)
|
||||
app.include_router(lens_router)
|
||||
app.include_router(plugin_router)
|
||||
app.include_router(cost_tracking_settings_router)
|
||||
app.include_router(prompt_caching_requests_router)
|
||||
|
|
|
|||
|
|
@ -1895,22 +1895,22 @@ model LiteLLM_WorkflowMessage {
|
|||
@@index([run_id])
|
||||
}
|
||||
|
||||
model LiteLLM_Engine {
|
||||
model LiteLLM_Lens {
|
||||
id String @id
|
||||
version Int @default(0)
|
||||
data Json
|
||||
}
|
||||
|
||||
model LiteLLM_EngineRun {
|
||||
model LiteLLM_LensRun {
|
||||
id String @id
|
||||
engine_id String
|
||||
lens_id String
|
||||
created_at DateTime
|
||||
data Json
|
||||
|
||||
@@index([engine_id, created_at])
|
||||
@@index([lens_id, created_at])
|
||||
}
|
||||
|
||||
model LiteLLM_EngineWorker {
|
||||
model LiteLLM_LensWorker {
|
||||
id String @id
|
||||
token_hash String @unique
|
||||
data Json
|
||||
|
|
|
|||
|
|
@ -1895,22 +1895,22 @@ model LiteLLM_WorkflowMessage {
|
|||
@@index([run_id])
|
||||
}
|
||||
|
||||
model LiteLLM_Engine {
|
||||
model LiteLLM_Lens {
|
||||
id String @id
|
||||
version Int @default(0)
|
||||
data Json
|
||||
}
|
||||
|
||||
model LiteLLM_EngineRun {
|
||||
model LiteLLM_LensRun {
|
||||
id String @id
|
||||
engine_id String
|
||||
lens_id String
|
||||
created_at DateTime
|
||||
data Json
|
||||
|
||||
@@index([engine_id, created_at])
|
||||
@@index([lens_id, created_at])
|
||||
}
|
||||
|
||||
model LiteLLM_EngineWorker {
|
||||
model LiteLLM_LensWorker {
|
||||
id String @id
|
||||
token_hash String @unique
|
||||
data Json
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import os
|
|||
|
||||
ALLOWED_FILES = [
|
||||
# The standalone Lens process reuses one client for its entire lifetime, without importing the proxy SDK.
|
||||
"../../litellm/proxy/engine/worker.py",
|
||||
"./litellm/proxy/engine/worker.py",
|
||||
"../../litellm/proxy/lens/worker.py",
|
||||
"./litellm/proxy/lens/worker.py",
|
||||
# local files
|
||||
"../../litellm/__init__.py",
|
||||
"../../litellm/llms/custom_httpx/http_handler.py",
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from prisma import Prisma
|
||||
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
from litellm.proxy.engine.models import Check, Engine, EngineSettings, Scope, Worker
|
||||
from litellm.proxy.engine.repository import EngineRepository, WriterDatabase
|
||||
from litellm.proxy.engine.state import claim_job, queue_job
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="function")
|
||||
async def engine_db() -> AsyncIterator[Prisma]:
|
||||
async with Prisma(datasource={"url": os.environ["DATABASE_URL"]}) as db:
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_workers_cannot_both_acquire_the_same_job(engine_db: Prisma) -> None:
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
scope: Final = Scope(team_id=uuid4().hex)
|
||||
repo: Final = EngineRepository(WriterDatabase(PrismaWrapper(engine_db)))
|
||||
engine: Final = Engine(
|
||||
id=uuid4().hex,
|
||||
scope=scope,
|
||||
settings=EngineSettings(name="Lease test", model="test", checks=(Check(id="c", instruction="Find retries"),)),
|
||||
created_at=now,
|
||||
next_run_at=now,
|
||||
budget_month=now.strftime("%Y-%m"),
|
||||
)
|
||||
await repo.create(queue_job(engine, now, uuid4().hex))
|
||||
try:
|
||||
workers: Final = tuple(Worker(id=uuid4().hex, name="worker", scope=scope, last_seen=now) for _ in range(2))
|
||||
results: Final = await asyncio.gather(
|
||||
*(repo.update(engine.id, lambda e, w=w: claim_job(e, w, now)) for w in workers)
|
||||
)
|
||||
stored: Final = await repo.get(engine.id)
|
||||
assert stored is not None
|
||||
assert stored.jobs[0].attempts == 1
|
||||
assert stored.jobs[0].worker_id in tuple(w.id for w in workers)
|
||||
assert tuple(r.jobs[0].worker_id for r in results if r) == (stored.jobs[0].worker_id, stored.jobs[0].worker_id)
|
||||
finally:
|
||||
await engine_db.execute_raw('DELETE FROM "LiteLLM_Engine" WHERE id=$1', engine.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_never_restores_revoked_access(engine_db: Prisma) -> None:
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
repo: Final = EngineRepository(WriterDatabase(PrismaWrapper(engine_db)))
|
||||
worker: Final = Worker(id=uuid4().hex, name="worker", scope=Scope(team_id=uuid4().hex), last_seen=now)
|
||||
token_hash: Final = uuid4().hex
|
||||
await repo.save_worker(worker, token_hash)
|
||||
try:
|
||||
await repo.save_worker(worker.model_copy(update={"revoked": True}))
|
||||
await repo.heartbeat(worker.id, now.isoformat())
|
||||
stored: Final = await repo.worker(token_hash)
|
||||
assert stored is not None and stored.revoked is True
|
||||
finally:
|
||||
await engine_db.execute_raw('DELETE FROM "LiteLLM_EngineWorker" WHERE id=$1', worker.id)
|
||||
173
tests/integration/database/test_lens_repository.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from prisma import Prisma
|
||||
from psycopg import sql
|
||||
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
from litellm.proxy.lens.models import Check, Lens, LensSettings, Scope, Worker
|
||||
from litellm.proxy.lens.repository import LensRepository, WriterDatabase
|
||||
from litellm.proxy.lens.state import claim_job, queue_job
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="function")
|
||||
async def lens_db() -> AsyncIterator[Prisma]:
|
||||
async with Prisma(datasource={"url": os.environ["DATABASE_URL"]}) as db:
|
||||
yield db
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_workers_cannot_both_acquire_the_same_job(lens_db: Prisma) -> None:
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
scope: Final = Scope(team_id=uuid4().hex)
|
||||
repo: Final = LensRepository(WriterDatabase(PrismaWrapper(lens_db)))
|
||||
lens: Final = Lens(
|
||||
id=uuid4().hex,
|
||||
scope=scope,
|
||||
settings=LensSettings(name="Lease test", model="test", checks=(Check(id="c", instruction="Find retries"),)),
|
||||
created_at=now,
|
||||
next_run_at=now,
|
||||
budget_month=now.strftime("%Y-%m"),
|
||||
)
|
||||
await repo.create(queue_job(lens, now, uuid4().hex))
|
||||
try:
|
||||
workers: Final = tuple(Worker(id=uuid4().hex, name="worker", scope=scope, last_seen=now) for _ in range(2))
|
||||
results: Final = await asyncio.gather(
|
||||
*(repo.update(lens.id, lambda e, w=w: claim_job(e, w, now)) for w in workers)
|
||||
)
|
||||
stored: Final = await repo.get(lens.id)
|
||||
assert stored is not None
|
||||
assert stored.jobs[0].attempts == 1
|
||||
assert stored.jobs[0].worker_id in tuple(w.id for w in workers)
|
||||
assert tuple(r.jobs[0].worker_id for r in results if r) == (stored.jobs[0].worker_id, stored.jobs[0].worker_id)
|
||||
finally:
|
||||
await lens_db.execute_raw('DELETE FROM "LiteLLM_Lens" WHERE id=$1', lens.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_never_restores_revoked_access(lens_db: Prisma) -> None:
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
repo: Final = LensRepository(WriterDatabase(PrismaWrapper(lens_db)))
|
||||
worker: Final = Worker(id=uuid4().hex, name="worker", scope=Scope(team_id=uuid4().hex), last_seen=now)
|
||||
token_hash: Final = uuid4().hex
|
||||
await repo.save_worker(worker, token_hash)
|
||||
try:
|
||||
await repo.save_worker(worker.model_copy(update={"revoked": True}))
|
||||
await repo.heartbeat(worker.id, now.isoformat())
|
||||
stored: Final = await repo.worker(token_hash)
|
||||
assert stored is not None and stored.revoked is True
|
||||
finally:
|
||||
await lens_db.execute_raw('DELETE FROM "LiteLLM_LensWorker" WHERE id=$1', worker.id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("populated", (False, True))
|
||||
@pytest.mark.parametrize("preceding_schema", (False, True))
|
||||
def test_lens_rename_preserves_saved_data_and_worker_credentials(populated: bool, preceding_schema: bool) -> None:
|
||||
migrations: Final = (
|
||||
Path(__file__).resolve().parents[3] / "litellm-proxy-extras" / "litellm_proxy_extras" / "migrations"
|
||||
)
|
||||
schema: Final = f"lens_migration_{uuid4().hex}"
|
||||
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
|
||||
try:
|
||||
connection.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
|
||||
connection.execute(sql.SQL("SET LOCAL search_path TO {}").format(sql.Identifier(schema)))
|
||||
for name in ("20260930000000_agent_engine", "20261001000000_lens_run_history"):
|
||||
connection.execute(sql.SQL((migrations / name / "migration.sql").read_text()))
|
||||
if populated:
|
||||
connection.execute(
|
||||
"""INSERT INTO "LiteLLM_Engine" VALUES ('lens', 7, '{"findings":[{"id":"finding"}]}');
|
||||
INSERT INTO "LiteLLM_EngineWorker" VALUES ('worker', 'token-hash', '{"analysis_key_id":"key"}');
|
||||
INSERT INTO "LiteLLM_EngineRun" VALUES ('batch', 'lens', '2026-01-01', '{"cost":1.25}')"""
|
||||
)
|
||||
if preceding_schema:
|
||||
first_schema: Final = f"lens_first_{uuid4().hex}"
|
||||
connection.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(first_schema)))
|
||||
connection.execute(
|
||||
sql.SQL("SET LOCAL search_path TO {}, {}").format(
|
||||
sql.Identifier(first_schema), sql.Identifier(schema)
|
||||
)
|
||||
)
|
||||
connection.execute(sql.SQL((migrations / "20261001100000_rename_lens" / "migration.sql").read_text()))
|
||||
connection.execute(sql.SQL((migrations / "20261001100000_rename_lens" / "migration.sql").read_text()))
|
||||
assert connection.execute('SELECT id, version, data FROM "LiteLLM_Lens"').fetchall() == (
|
||||
[("lens", 7, {"findings": [{"id": "finding"}]})] if populated else []
|
||||
)
|
||||
assert connection.execute('SELECT id, token_hash, data FROM "LiteLLM_LensWorker"').fetchall() == (
|
||||
[("worker", "token-hash", {"analysis_key_id": "key"})] if populated else []
|
||||
)
|
||||
assert connection.execute('SELECT id, lens_id, data FROM "LiteLLM_LensRun"').fetchall() == (
|
||||
[("batch", "lens", {"cost": 1.25})] if populated else []
|
||||
)
|
||||
finally:
|
||||
connection.rollback()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entrypoint", ("proxy", "extras-v1", "extras-v2"))
|
||||
@pytest.mark.parametrize("legacy_table", ("LiteLLM_Engine", "LiteLLM_EngineRun", "LiteLLM_EngineWorker"))
|
||||
def test_db_push_refuses_legacy_lens_data(monkeypatch: pytest.MonkeyPatch, entrypoint: str, legacy_table: str) -> None:
|
||||
from litellm_proxy_extras.utils import ProxyExtrasDBManager
|
||||
|
||||
from litellm.proxy.db.prisma_client import PrismaManager
|
||||
|
||||
database_url: Final = os.environ["DATABASE_URL"]
|
||||
schema: Final = f"lens_push_{uuid4().hex}"
|
||||
parsed: Final = urlsplit(database_url)
|
||||
scoped: Final = urlunsplit(parsed._replace(query=urlencode({**dict(parse_qsl(parsed.query)), "schema": schema})))
|
||||
with psycopg.connect(database_url, autocommit=True) as connection:
|
||||
connection.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
|
||||
try:
|
||||
connection.execute(
|
||||
sql.SQL("CREATE TABLE {} (id TEXT PRIMARY KEY, data JSONB)").format(
|
||||
sql.Identifier(schema, legacy_table)
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sql.SQL("INSERT INTO {} VALUES ('saved', '{{\"keep\":true}}')").format(
|
||||
sql.Identifier(schema, legacy_table)
|
||||
)
|
||||
)
|
||||
monkeypatch.setenv("DATABASE_URL", scoped)
|
||||
setup: Final = (
|
||||
PrismaManager.setup_database if entrypoint == "proxy" else ProxyExtrasDBManager.setup_database
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Legacy Lens tables exist"):
|
||||
setup(use_migrate=False, use_v2_resolver=entrypoint == "extras-v2")
|
||||
assert connection.execute(
|
||||
sql.SQL("SELECT id, data FROM {}").format(sql.Identifier(schema, legacy_table))
|
||||
).fetchall() == [("saved", {"keep": True})]
|
||||
finally:
|
||||
connection.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
|
||||
|
||||
|
||||
def test_db_push_creates_fresh_lens_tables_and_preserves_them_on_restart(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from litellm.proxy.db.prisma_client import PrismaManager
|
||||
|
||||
database_url: Final = os.environ["DATABASE_URL"]
|
||||
schema: Final = f"lens_fresh_push_{uuid4().hex}"
|
||||
parsed: Final = urlsplit(database_url)
|
||||
scoped: Final = urlunsplit(parsed._replace(query=urlencode({**dict(parse_qsl(parsed.query)), "schema": schema})))
|
||||
with psycopg.connect(database_url, autocommit=True) as connection:
|
||||
connection.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
|
||||
try:
|
||||
monkeypatch.setenv("DATABASE_URL", scoped)
|
||||
assert PrismaManager.setup_database(use_migrate=False)
|
||||
connection.execute(
|
||||
sql.SQL("INSERT INTO {} (id, data) VALUES ('saved', '{{\"keep\":true}}')").format(
|
||||
sql.Identifier(schema, "LiteLLM_Lens")
|
||||
)
|
||||
)
|
||||
assert PrismaManager.setup_database(use_migrate=False)
|
||||
assert connection.execute(
|
||||
sql.SQL("SELECT id, data FROM {}").format(sql.Identifier(schema, "LiteLLM_Lens"))
|
||||
).fetchall() == [("saved", {"keep": True})]
|
||||
finally:
|
||||
connection.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
|
||||
|
|
@ -12,10 +12,10 @@ from tests.integration._support.process import owned_proxy
|
|||
from tests.integration.pricing.test_off_peak_pricing import off_peak_window
|
||||
|
||||
|
||||
def delete_lens(engine_id: str) -> None:
|
||||
write_rows('DELETE FROM "LiteLLM_EngineRun" WHERE engine_id=%s', (engine_id,))
|
||||
write_rows('DELETE FROM "LiteLLM_Engine" WHERE id=%s', (engine_id,))
|
||||
assert read_rows('SELECT id FROM "LiteLLM_Engine" WHERE id=%s', (engine_id,)) == []
|
||||
def delete_lens(lens_id: str) -> None:
|
||||
write_rows('DELETE FROM "LiteLLM_LensRun" WHERE lens_id=%s', (lens_id,))
|
||||
write_rows('DELETE FROM "LiteLLM_Lens" WHERE id=%s', (lens_id,))
|
||||
assert read_rows('SELECT id FROM "LiteLLM_Lens" WHERE id=%s', (lens_id,)) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("off_peak", (False, True))
|
||||
|
|
@ -37,12 +37,12 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
|
|||
key: Final = scenario.key(models=[model], max_budget=1)
|
||||
key_id: Final = sha256(key.encode()).hexdigest()
|
||||
worker: Final = gateway.post(
|
||||
"/engine/workers/register", {"name": "Billing regression", "analysis_key_id": key_id}
|
||||
"/lens/workers/register", {"name": "Billing regression", "analysis_key_id": key_id}
|
||||
)
|
||||
worker_id: Final = string_value(object_value(worker["worker"])["id"])
|
||||
scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_EngineWorker" WHERE id=%s', (worker_id,))
|
||||
engine: Final = gateway.post(
|
||||
"/engine",
|
||||
scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_LensWorker" WHERE id=%s', (worker_id,))
|
||||
lens: Final = gateway.post(
|
||||
"/lens",
|
||||
{
|
||||
"name": "Billing regression",
|
||||
"model": model,
|
||||
|
|
@ -51,17 +51,17 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
|
|||
"source": "requests",
|
||||
},
|
||||
)
|
||||
engine_id: Final = string_value(engine["id"])
|
||||
scenario.cleanups.callback(delete_lens, engine_id)
|
||||
lens_id: Final = string_value(lens["id"])
|
||||
scenario.cleanups.callback(delete_lens, lens_id)
|
||||
worker_key: Final = string_value(worker["token"])
|
||||
unauthorized: Final = gateway.request(
|
||||
"POST", "/engine/workers/register", {"name": "Denied", "analysis_key_id": key_id}, key=key
|
||||
"POST", "/lens/workers/register", {"name": "Denied", "analysis_key_id": key_id}, key=key
|
||||
)
|
||||
assert unauthorized.status_code == 403, unauthorized.text
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
claims: Final = tuple(
|
||||
pool.map(
|
||||
lambda _: gateway.request("POST", "/engine/worker/claim?protocol_version=2", {}, key=worker_key),
|
||||
lambda _: gateway.request("POST", "/lens/worker/claim?protocol_version=2", {}, key=worker_key),
|
||||
range(8),
|
||||
)
|
||||
)
|
||||
|
|
@ -69,9 +69,9 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
|
|||
winners: Final = tuple(response.json() for response in claims if response.json() is not None)
|
||||
assert len(winners) == 1
|
||||
claim: Final = object_value(winners[0])
|
||||
assert claim["engine_id"] == engine_id
|
||||
assert claim["lens_id"] == lens_id
|
||||
job_id: Final = string_value(object_value(claim["job"])["id"])
|
||||
path: Final = f"/engine/worker/{engine_id}/{job_id}/model"
|
||||
path: Final = f"/lens/worker/{lens_id}/{job_id}/model"
|
||||
result: Final = gateway.post(path, {"prompt": "Inspect this run", "purpose": "extract"}, key=worker_key)
|
||||
expected: Final = (20 * 0.000001 + 20 * 0.000002) * (0.5 if off_peak else 1)
|
||||
assert result["cost"] == pytest.approx(expected)
|
||||
|
|
@ -81,7 +81,7 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
|
|||
seconds=70,
|
||||
)
|
||||
assert rows[0]["spend"] == pytest.approx(expected)
|
||||
assert gateway.get(f"/engine/{engine_id}")["spent"] == pytest.approx(expected)
|
||||
assert gateway.get(f"/lens/{lens_id}")["spent"] == pytest.approx(expected)
|
||||
raw_hash: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
|
|
@ -105,11 +105,11 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
|
|||
gateway.post("/key/block", {"key": key})
|
||||
blocked: Final = gateway.request("POST", path, {"prompt": "Must not run", "purpose": "extract"}, key=worker_key)
|
||||
assert blocked.status_code == 400, blocked.text
|
||||
assert gateway.get(f"/engine/{engine_id}")["spent"] == pytest.approx(expected)
|
||||
assert gateway.get(f"/lens/{lens_id}")["spent"] == pytest.approx(expected)
|
||||
replacement: Final = scenario.key(models=[model], rpm_limit=1)
|
||||
replacement_id: Final = sha256(replacement.encode()).hexdigest()
|
||||
changed: Final = gateway.request(
|
||||
"PUT", f"/engine/workers/{worker_id}/billing-key", {"analysis_key_id": replacement_id}
|
||||
"PUT", f"/lens/workers/{worker_id}/billing-key", {"analysis_key_id": replacement_id}
|
||||
)
|
||||
assert changed.status_code == 200, changed.text
|
||||
billed_replacement: Final = gateway.post(
|
||||
|
|
@ -124,17 +124,17 @@ def test_lens_bills_selected_key_and_rechecks_its_permissions(gateway: Gateway,
|
|||
seconds=70,
|
||||
)
|
||||
assert second_rows[0]["spend"] == pytest.approx(expected)
|
||||
revoked: Final = gateway.request("DELETE", f"/engine/workers/{worker_id}")
|
||||
revoked: Final = gateway.request("DELETE", f"/lens/workers/{worker_id}")
|
||||
assert revoked.status_code == 200, revoked.text
|
||||
denied_worker: Final = gateway.request(
|
||||
"POST", path, {"prompt": "Must not run", "purpose": "extract"}, key=worker_key
|
||||
)
|
||||
assert denied_worker.status_code == 401, denied_worker.text
|
||||
forbidden_change: Final = gateway.request(
|
||||
"PUT", f"/engine/workers/{worker_id}/billing-key", {"analysis_key_id": replacement_id}
|
||||
"PUT", f"/lens/workers/{worker_id}/billing-key", {"analysis_key_id": replacement_id}
|
||||
)
|
||||
assert forbidden_change.status_code == 409, forbidden_change.text
|
||||
gateway.post(f"/engine/{engine_id}/cancel", {})
|
||||
gateway.post(f"/lens/{lens_id}/cancel", {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cancel_on_disconnect", (False, True))
|
||||
|
|
@ -174,19 +174,19 @@ def test_worker_spend_logs_do_not_expose_investigation_content(
|
|||
seconds=70,
|
||||
)
|
||||
assert marker in str(retained[0]), "Control must prove this proxy retains ordinary prompts"
|
||||
worker: Final = isolated.post("/engine/workers/register", {"analysis_key_id": key_id})
|
||||
worker: Final = isolated.post("/lens/workers/register", {"analysis_key_id": key_id})
|
||||
worker_id: Final = string_value(object_value(worker["worker"])["id"])
|
||||
scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_EngineWorker" WHERE id=%s', (worker_id,))
|
||||
engine: Final = isolated.post(
|
||||
"/engine", {"name": "Log privacy", "model": model, "enabled": False, "context": "Find problems"}
|
||||
scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_LensWorker" WHERE id=%s', (worker_id,))
|
||||
lens: Final = isolated.post(
|
||||
"/lens", {"name": "Log privacy", "model": model, "enabled": False, "context": "Find problems"}
|
||||
)
|
||||
engine_id: Final = string_value(engine["id"])
|
||||
scenario.cleanups.callback(delete_lens, engine_id)
|
||||
lens_id: Final = string_value(lens["id"])
|
||||
scenario.cleanups.callback(delete_lens, lens_id)
|
||||
worker_token: Final = string_value(worker["token"])
|
||||
claim: Final = isolated.post("/engine/worker/claim?protocol_version=2", {}, key=worker_token)
|
||||
claim: Final = isolated.post("/lens/worker/claim?protocol_version=2", {}, key=worker_token)
|
||||
job_id: Final = string_value(object_value(claim["job"])["id"])
|
||||
result: Final = isolated.post(
|
||||
f"/engine/worker/{engine_id}/{job_id}/model", {"prompt": marker, "purpose": "extract"}, key=worker_token
|
||||
f"/lens/worker/{lens_id}/{job_id}/model", {"prompt": marker, "purpose": "extract"}, key=worker_token
|
||||
)
|
||||
assert result["content"], "The worker must still receive model output"
|
||||
rows: Final = eventually(
|
||||
|
|
@ -200,4 +200,4 @@ def test_worker_spend_logs_do_not_expose_investigation_content(
|
|||
assert float(rows[0]["spend"]) == pytest.approx(result["cost"])
|
||||
assert marker not in str(rows[0])
|
||||
assert result["content"] not in str(rows[0]["response"])
|
||||
isolated.post(f"/engine/{engine_id}/cancel", {})
|
||||
isolated.post(f"/lens/{lens_id}/cancel", {})
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ from typing import Final
|
|||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy.engine.analysis import analyze_sample
|
||||
from litellm.proxy.engine.inference import _SYSTEM
|
||||
from litellm.proxy.engine.models import (
|
||||
from litellm.proxy.lens.analysis import analyze_sample
|
||||
from litellm.proxy.lens.inference import _SYSTEM
|
||||
from litellm.proxy.lens.models import (
|
||||
Check,
|
||||
Claim,
|
||||
Coverage,
|
||||
EngineSettings,
|
||||
LensSettings,
|
||||
Execution,
|
||||
ExecutionContent,
|
||||
Finding,
|
||||
|
|
@ -90,7 +90,7 @@ async def evaluate(
|
|||
feedback: tuple[Finding, ...] = (),
|
||||
) -> dict[str, object]:
|
||||
records: Final = MappingProxyType({case.name: fixtures(case) for case in cases})
|
||||
settings: Final = EngineSettings(
|
||||
settings: Final = LensSettings(
|
||||
name="Quality evaluation",
|
||||
model=model_name,
|
||||
checks=checks,
|
||||
|
|
@ -100,7 +100,7 @@ async def evaluate(
|
|||
)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
claim: Final = Claim(
|
||||
engine_id="evaluation",
|
||||
lens_id="evaluation",
|
||||
findings=feedback,
|
||||
job=Job(id="evaluation", created_at=now, start=now, end=now, settings=settings, revision=1),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ from litellm import Router
|
|||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.engine import endpoints
|
||||
from litellm.proxy.engine.models import Check, Coverage, EngineSettings, ModelRequest, Progress, Result, RunRequest
|
||||
from litellm.proxy.lens import endpoints
|
||||
from litellm.proxy.lens.models import Check, Coverage, LensSettings, ModelRequest, Progress, Result, RunRequest
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
|
||||
|
||||
|
|
@ -61,13 +61,13 @@ async def lens_database() -> AsyncIterator[PrismaClient]:
|
|||
@pytest.mark.asyncio
|
||||
async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database: PrismaClient) -> None:
|
||||
admin: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
settings: Final = EngineSettings(
|
||||
settings: Final = LensSettings(
|
||||
name="Lifecycle regression",
|
||||
model="lens-test-analysis",
|
||||
enabled=False,
|
||||
checks=(Check(id="retries", instruction="Find unrecovered retries"),),
|
||||
)
|
||||
engine: Final = await endpoints.create_engine(settings, admin)
|
||||
lens: Final = await endpoints.create_lens(settings, admin)
|
||||
key_id: Final = hashlib.sha256(uuid4().bytes).hexdigest()
|
||||
await lens_database.db.litellm_verificationtoken.create(data={"token": key_id, "models": ["lens-test-analysis"]})
|
||||
registration: Final = await endpoints.register_worker(
|
||||
|
|
@ -76,17 +76,17 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
credentials: Final = HTTPAuthorizationCredentials(scheme="Bearer", credentials=registration.token)
|
||||
worker: Final = await endpoints.worker_auth(credentials)
|
||||
try:
|
||||
assert engine.jobs[0].status == "queued"
|
||||
assert lens.jobs[0].status == "queued"
|
||||
stored_worker: Final = await endpoints.repository().worker(
|
||||
hashlib.sha256(registration.token.encode()).hexdigest()
|
||||
)
|
||||
assert stored_worker is not None and stored_worker.id == worker.id
|
||||
assert worker.id == registration.worker.id
|
||||
listing: Final = await endpoints.list_engines(admin)
|
||||
assert engine.id in tuple(e.id for e in listing.engines)
|
||||
listing: Final = await endpoints.list_lenses(admin)
|
||||
assert lens.id in tuple(e.id for e in listing.lenses)
|
||||
assert worker.id in tuple(w.id for w in listing.workers)
|
||||
claims: Final = await asyncio.gather(
|
||||
*(endpoints.claim_candidate(engine, worker, datetime.now(timezone.utc)) for _ in range(8))
|
||||
*(endpoints.claim_candidate(lens, worker, datetime.now(timezone.utc)) for _ in range(8))
|
||||
)
|
||||
winners: Final = tuple(claim for claim in claims if claim is not None)
|
||||
assert len(winners) == 1
|
||||
|
|
@ -94,16 +94,16 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
assert claimed.job.worker_id == worker.id
|
||||
assert (
|
||||
await endpoints.claim_candidate(
|
||||
await endpoints.get_engine(engine.id, worker.scope), worker, datetime.now(timezone.utc)
|
||||
await endpoints.get_lens(lens.id, worker.scope), worker, datetime.now(timezone.utc)
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert await endpoints.progress(
|
||||
engine.id, claimed.job.id, Progress(stage="Reviewing", coverage=Coverage(screened=2)), worker
|
||||
lens.id, claimed.job.id, Progress(stage="Reviewing", coverage=Coverage(screened=2)), worker
|
||||
)
|
||||
assert await endpoints.heartbeat(engine.id, claimed.job.id, worker)
|
||||
assert await endpoints.heartbeat(lens.id, claimed.job.id, worker)
|
||||
response: Final = await endpoints.model(
|
||||
engine.id,
|
||||
lens.id,
|
||||
claimed.job.id,
|
||||
ModelRequest(prompt="Return an empty observations list", purpose="extract"),
|
||||
worker,
|
||||
|
|
@ -111,7 +111,7 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
{
|
||||
"type": "http",
|
||||
"scheme": "http",
|
||||
"path": "/engine/worker/model",
|
||||
"path": "/lens/worker/model",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 1234),
|
||||
}
|
||||
|
|
@ -120,7 +120,7 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
assert '"observations"' in response.content
|
||||
with pytest.raises(HTTPException) as denied_ip:
|
||||
await endpoints.model(
|
||||
engine.id,
|
||||
lens.id,
|
||||
claimed.job.id,
|
||||
ModelRequest(prompt="Must not run", purpose="extract"),
|
||||
worker,
|
||||
|
|
@ -128,7 +128,7 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
{
|
||||
"type": "http",
|
||||
"scheme": "http",
|
||||
"path": "/engine/worker/model",
|
||||
"path": "/lens/worker/model",
|
||||
"headers": [(b"x-forwarded-for", b"127.0.0.1")],
|
||||
"client": ("192.0.2.1", 1234),
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
)
|
||||
assert denied_ip.value.status_code == 403
|
||||
forwarded: Final = await endpoints.model(
|
||||
engine.id,
|
||||
lens.id,
|
||||
claimed.job.id,
|
||||
ModelRequest(prompt="Return an empty observations list", purpose="extract"),
|
||||
worker,
|
||||
|
|
@ -144,7 +144,7 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
{
|
||||
"type": "http",
|
||||
"scheme": "http",
|
||||
"path": "/engine/worker/model",
|
||||
"path": "/lens/worker/model",
|
||||
"headers": [(b"x-forwarded-for", b"127.0.0.1")],
|
||||
"client": ("192.0.2.100", 1234),
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
assert '"observations"' in forwarded.content
|
||||
with pytest.raises(HTTPException) as spoofed_chain:
|
||||
await endpoints.model(
|
||||
engine.id,
|
||||
lens.id,
|
||||
claimed.job.id,
|
||||
ModelRequest(prompt="Must not run", purpose="extract"),
|
||||
worker,
|
||||
|
|
@ -161,14 +161,14 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
{
|
||||
"type": "http",
|
||||
"scheme": "http",
|
||||
"path": "/engine/worker/model",
|
||||
"path": "/lens/worker/model",
|
||||
"headers": [(b"x-forwarded-for", b"127.0.0.1, 192.0.2.1")],
|
||||
"client": ("192.0.2.100", 1234),
|
||||
}
|
||||
),
|
||||
)
|
||||
assert spoofed_chain.value.status_code == 403
|
||||
charged: Final = await endpoints.get_engine(engine.id, worker.scope)
|
||||
charged: Final = await endpoints.get_lens(lens.id, worker.scope)
|
||||
assert charged.spent == pytest.approx(response.cost + forwarded.cost)
|
||||
assert charged.jobs[0].cost == pytest.approx(response.cost + forwarded.cost)
|
||||
legacy: Final = worker.model_copy(update={"analysis_key_id": None})
|
||||
|
|
@ -178,37 +178,37 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
with pytest.raises(HTTPException) as needs_billing:
|
||||
await endpoints.claim(authenticated_legacy, protocol_version=2)
|
||||
assert needs_billing.value.status_code == 409
|
||||
assert await endpoints.heartbeat(engine.id, claimed.job.id, authenticated_legacy)
|
||||
assert await endpoints.heartbeat(lens.id, claimed.job.id, authenticated_legacy)
|
||||
finished: Final = await endpoints.result(
|
||||
engine.id, claimed.job.id, Result(coverage=Coverage(screened=2)), authenticated_legacy
|
||||
lens.id, claimed.job.id, Result(coverage=Coverage(screened=2)), authenticated_legacy
|
||||
)
|
||||
assert finished.jobs[0].status == "completed"
|
||||
assert finished.jobs[0].coverage.screened == 2
|
||||
assert finished.last_scan_at == claimed.job.end
|
||||
assert finished.next_run_at > finished.jobs[0].finished_at
|
||||
assert await endpoints.result(engine.id, claimed.job.id, Result(coverage=Coverage()), worker) == finished
|
||||
assert await endpoints.result(lens.id, claimed.job.id, Result(coverage=Coverage()), worker) == finished
|
||||
with pytest.raises(HTTPException) as stale:
|
||||
await endpoints.heartbeat(engine.id, claimed.job.id, worker)
|
||||
await endpoints.heartbeat(lens.id, claimed.job.id, worker)
|
||||
assert stale.value.status_code == 409
|
||||
edited: Final = await endpoints.update_engine(
|
||||
engine.id, settings.model_copy(update={"interval_minutes": 7}), admin
|
||||
edited: Final = await endpoints.update_lens(
|
||||
lens.id, settings.model_copy(update={"interval_minutes": 7}), admin
|
||||
)
|
||||
assert edited.revision == engine.revision + 1
|
||||
rerun: Final = await endpoints.run_engine(engine.id, RunRequest(lookback_hours=3), admin)
|
||||
assert edited.revision == lens.revision + 1
|
||||
rerun: Final = await endpoints.run_lens(lens.id, RunRequest(lookback_hours=3), admin)
|
||||
assert rerun.jobs[0].settings.interval_minutes == 7
|
||||
assert rerun.jobs[0].created_at - rerun.jobs[0].start == timedelta(hours=3)
|
||||
history: Final = await endpoints.list_runs(engine.id, admin, offset=0)
|
||||
history: Final = await endpoints.list_runs(lens.id, admin, offset=0)
|
||||
assert {job.id for job in history} == {claimed.job.id, rerun.jobs[0].id}
|
||||
archived: Final = await endpoints.read_run(engine.id, claimed.job.id, admin)
|
||||
archived: Final = await endpoints.read_run(lens.id, claimed.job.id, admin)
|
||||
assert archived == finished.jobs[0]
|
||||
assert archived.settings.interval_minutes == 15
|
||||
assert archived.findings == ()
|
||||
with pytest.raises(HTTPException) as foreign_history:
|
||||
await endpoints.read_run(engine.id, claimed.job.id, UserAPIKeyAuth(team_id="other"))
|
||||
await endpoints.read_run(lens.id, claimed.job.id, UserAPIKeyAuth(team_id="other"))
|
||||
assert foreign_history.value.status_code == 403
|
||||
cancelled: Final = await endpoints.cancel_engine(engine.id, admin)
|
||||
cancelled: Final = await endpoints.cancel_lens(lens.id, admin)
|
||||
assert cancelled.jobs[0].status == "cancelled"
|
||||
assert await endpoints.cancel_engine(engine.id, admin) == cancelled
|
||||
assert await endpoints.cancel_lens(lens.id, admin) == cancelled
|
||||
assert await endpoints.revoke_worker(worker.id, admin)
|
||||
assert await endpoints.repository().set_worker_billing(worker.id, key_id) is None
|
||||
with pytest.raises(HTTPException) as revoked_billing:
|
||||
|
|
@ -218,10 +218,10 @@ async def test_scan_lifecycle_persists_results_and_revokes_worker(lens_database:
|
|||
await endpoints.worker_auth(credentials)
|
||||
assert revoked.value.status_code == 401
|
||||
with pytest.raises(HTTPException) as foreign:
|
||||
await endpoints.get_engine(engine.id, endpoints.Scope(team_id="other"))
|
||||
await endpoints.get_lens(lens.id, endpoints.Scope(team_id="other"))
|
||||
assert foreign.value.status_code == 404
|
||||
finally:
|
||||
await lens_database.db.execute_raw('DELETE FROM "LiteLLM_EngineRun" WHERE engine_id=$1', engine.id)
|
||||
await lens_database.db.execute_raw('DELETE FROM "LiteLLM_Engine" WHERE id=$1', engine.id)
|
||||
await lens_database.db.execute_raw('DELETE FROM "LiteLLM_EngineWorker" WHERE id=$1', worker.id)
|
||||
await lens_database.db.execute_raw('DELETE FROM "LiteLLM_LensRun" WHERE lens_id=$1', lens.id)
|
||||
await lens_database.db.execute_raw('DELETE FROM "LiteLLM_Lens" WHERE id=$1', lens.id)
|
||||
await lens_database.db.execute_raw('DELETE FROM "LiteLLM_LensWorker" WHERE id=$1', worker.id)
|
||||
await lens_database.db.execute_raw('DELETE FROM "LiteLLM_VerificationToken" WHERE token=$1', key_id)
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ from queue import SimpleQueue
|
|||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from engine.models import (
|
||||
from lens.models import (
|
||||
Claim,
|
||||
EngineSettings,
|
||||
LensSettings,
|
||||
Execution,
|
||||
ExecutionContent,
|
||||
Job,
|
||||
|
|
@ -17,7 +17,7 @@ from engine.models import (
|
|||
Sample,
|
||||
TracePart,
|
||||
)
|
||||
from engine.worker import EngineWorker
|
||||
from lens.worker import LensWorker
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
|
|
@ -25,7 +25,7 @@ async def main() -> None:
|
|||
claims: Final = iter(("full", "healthy"))
|
||||
saved: Final = SimpleQueue[Result]()
|
||||
pages: Final = SimpleQueue[str]()
|
||||
settings: Final = EngineSettings(name="Storage recovery", model="unused", context="Finish the task", concurrency=1)
|
||||
settings: Final = LensSettings(name="Storage recovery", model="unused", context="Finish the task", concurrency=1)
|
||||
execution: Final = Execution(
|
||||
id="run", source="traces", trace_id="trace", team_id="", name="Task", start_time="", span_count=10000
|
||||
)
|
||||
|
|
@ -34,7 +34,7 @@ async def main() -> None:
|
|||
path: Final = request.url.path
|
||||
if path.endswith("/claim"):
|
||||
claim: Final = Claim(
|
||||
engine_id="lens",
|
||||
lens_id="lens",
|
||||
job=Job(id=next(claims), created_at=now, start=now, end=now, settings=settings, revision=1),
|
||||
findings=(),
|
||||
)
|
||||
|
|
@ -71,7 +71,7 @@ async def main() -> None:
|
|||
return httpx.Response(200, json=True)
|
||||
|
||||
async with httpx.AsyncClient(base_url="https://proxy.test", transport=httpx.MockTransport(handle)) as client:
|
||||
worker: Final = EngineWorker(client)
|
||||
worker: Final = LensWorker(client)
|
||||
assert await worker.run_once()
|
||||
failed: Final = saved.get_nowait()
|
||||
assert failed.error.startswith("Worker temporary storage failed.")
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ from typing import Final
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.engine.analysis import Candidate, Examined, evidence_valid, extract, investigate, partition_content
|
||||
from litellm.proxy.engine.models import (
|
||||
from litellm.proxy.lens.analysis import Candidate, Examined, evidence_valid, extract, investigate, partition_content
|
||||
from litellm.proxy.lens.models import (
|
||||
Claim,
|
||||
Coverage,
|
||||
Evidence,
|
||||
|
|
@ -18,14 +18,14 @@ from litellm.proxy.engine.models import (
|
|||
Sample,
|
||||
TracePart,
|
||||
)
|
||||
from litellm.proxy.engine.state import queue_job
|
||||
from tests.unit.proxy.engine.test_state import NOW, engine, finding
|
||||
from litellm.proxy.lens.state import queue_job
|
||||
from tests.unit.proxy.lens.test_state import NOW, lens, finding
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("outcome", ("complete", "cancel", "failure"))
|
||||
async def test_parallel_review_shares_one_model_limit_and_cleans_up(outcome: str) -> None:
|
||||
from litellm.proxy.engine.analysis import ANALYSIS_CONCURRENCY, analyze_sample
|
||||
from litellm.proxy.lens.analysis import ANALYSIS_CONCURRENCY, analyze_sample
|
||||
|
||||
executions: Final = tuple(
|
||||
Execution(id=str(i), source="traces", trace_id=str(i), team_id="alpha", name="run", start_time="", span_count=6)
|
||||
|
|
@ -70,7 +70,7 @@ async def test_parallel_review_shares_one_model_limit_and_cleans_up(outcome: str
|
|||
if stage == "Reading executions":
|
||||
counts.put(coverage.screened)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
task: Final = asyncio.create_task(
|
||||
analyze_sample(claim, Sample(executions=executions, eligible=len(executions)), read, model, progress)
|
||||
)
|
||||
|
|
@ -101,7 +101,7 @@ async def test_parallel_review_shares_one_model_limit_and_cleans_up(outcome: str
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_independent_investigations_overlap_and_report_completions() -> None:
|
||||
from litellm.proxy.engine.analysis import investigate_candidates
|
||||
from litellm.proxy.lens.analysis import investigate_candidates
|
||||
|
||||
arrived: Final = SimpleQueue[str]()
|
||||
progress_counts: Final = SimpleQueue[int]()
|
||||
|
|
@ -124,7 +124,7 @@ async def test_independent_investigations_overlap_and_report_completions() -> No
|
|||
candidates: Final = tuple(
|
||||
Candidate(check_id="retries", title=str(i), hypothesis="Investigate", execution_ids=()) for i in range(2)
|
||||
)
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
results: Final = tuple(
|
||||
[
|
||||
result
|
||||
|
|
@ -185,7 +185,7 @@ async def test_reviewer_sees_final_outcome_and_catalog_across_pages() -> None:
|
|||
assert pages.qsize() == 2
|
||||
return ModelResult(content='{"observations":[],"cannot_assess":false}', cost=0)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await extract(claim, execution, read, model)
|
||||
assert root in result.parts
|
||||
assert not result.cannot_assess
|
||||
|
|
@ -193,7 +193,7 @@ async def test_reviewer_sees_final_outcome_and_catalog_across_pages() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewer_fetches_targeted_evidence_and_rejects_outside_catalog_reads() -> None:
|
||||
from litellm.proxy.engine.analysis import Observation, SpanRead, TraceReview
|
||||
from litellm.proxy.lens.analysis import Observation, SpanRead, TraceReview
|
||||
|
||||
execution: Final = Execution(
|
||||
id="run", source="traces", trace_id="t", team_id="", name="run", start_time="", span_count=2
|
||||
|
|
@ -246,7 +246,7 @@ async def test_reviewer_fetches_targeted_evidence_and_rejects_outside_catalog_re
|
|||
cost=0,
|
||||
)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await extract(claim, execution, read, model)
|
||||
assert len(result.observations) == 1
|
||||
assert result.observations[0].evidence[0].quote == "Verified result: failed"
|
||||
|
|
@ -255,7 +255,7 @@ async def test_reviewer_fetches_targeted_evidence_and_rejects_outside_catalog_re
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewer_stops_repeated_read_requests() -> None:
|
||||
from litellm.proxy.engine.analysis import SpanRead, TraceReview
|
||||
from litellm.proxy.lens.analysis import SpanRead, TraceReview
|
||||
|
||||
execution: Final = Execution(
|
||||
id="run", source="traces", trace_id="t", team_id="", name="run", start_time="", span_count=1
|
||||
|
|
@ -276,7 +276,7 @@ async def test_reviewer_stops_repeated_read_requests() -> None:
|
|||
content=TraceReview(reads=(SpanRead(span_id="01"),), cannot_assess=True).model_dump_json(), cost=0
|
||||
)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await extract(claim, execution, read, model)
|
||||
assert result.cannot_assess
|
||||
assert reads.qsize() == 2
|
||||
|
|
@ -311,7 +311,7 @@ async def test_investigator_rejects_a_fabricated_quote() -> None:
|
|||
async def read(_execution_id: str, _cursor: str, _offset: int) -> ExecutionContent:
|
||||
return ExecutionContent(execution=execution, parts=examined.parts)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await investigate(
|
||||
claim,
|
||||
Candidate(check_id="retries", title="Retries", hypothesis="Unrecovered", execution_ids=("run1",)),
|
||||
|
|
@ -353,7 +353,7 @@ async def test_assessable_content_is_not_overridden_by_unknown_chunks(paginated:
|
|||
unavailable: Final = "false" if "verified result" in request.prompt else "true"
|
||||
return ModelResult(content='{"observations":[],"cannot_assess":' + unavailable + "}", cost=0)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await extract(claim, execution, read, model)
|
||||
assert result.cannot_assess is not assessable
|
||||
|
||||
|
|
@ -382,7 +382,7 @@ async def test_investigator_keeps_final_outcome_ahead_of_repeated_model_history(
|
|||
async def read(_execution_id: str, _cursor: str, _offset: int) -> ExecutionContent:
|
||||
return ExecutionContent(execution=execution, parts=examined.parts)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await investigate(
|
||||
claim,
|
||||
Candidate(check_id="retries", title="Retries", hypothesis="Unrecovered", execution_ids=("run1",)),
|
||||
|
|
@ -425,7 +425,7 @@ async def test_oversized_model_evidence_is_retried_and_quotes_still_verified(
|
|||
cost=0,
|
||||
)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await extract(claim, execution, read, model)
|
||||
assert len(result.observations) == int(accepted)
|
||||
assert result.cannot_assess is not accepted
|
||||
|
|
@ -436,7 +436,7 @@ async def test_oversized_model_evidence_is_retried_and_quotes_still_verified(
|
|||
async def test_invalid_model_output_has_only_one_repair_attempt() -> None:
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.proxy.engine.analysis import Extraction, structured_response
|
||||
from litellm.proxy.lens.analysis import Extraction, structured_response
|
||||
|
||||
attempts: Final = iter((1, 2))
|
||||
|
||||
|
|
@ -451,8 +451,8 @@ async def test_invalid_model_output_has_only_one_repair_attempt() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grouping_consolidates_prior_batches_and_reports_real_progress() -> None:
|
||||
from litellm.proxy.engine.analysis import Clusters, Observation, cluster_batches
|
||||
from litellm.proxy.engine.models import Coverage
|
||||
from litellm.proxy.lens.analysis import Clusters, Observation, cluster_batches
|
||||
from litellm.proxy.lens.models import Coverage
|
||||
|
||||
candidate: Final = Candidate(
|
||||
check_id="retries", title="Outage", hypothesis="Tool unavailable", execution_ids=("run1",)
|
||||
|
|
@ -517,7 +517,7 @@ async def test_investigator_can_cite_a_later_page_or_offset(later_span: str) ->
|
|||
assert execution_id == "run1" and offset == 8000
|
||||
return ExecutionContent(execution=execution, parts=(later,))
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await investigate(
|
||||
claim,
|
||||
Candidate(check_id="retries", title="Retries", hypothesis="Unrecovered", execution_ids=("run1",)),
|
||||
|
|
@ -530,7 +530,7 @@ async def test_investigator_can_cite_a_later_page_or_offset(later_span: str) ->
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thousands_of_matching_runs_keep_all_members_without_a_growing_model_prompt() -> None:
|
||||
from litellm.proxy.engine.analysis import Clusters, Observation, cluster_batches, observation_batches
|
||||
from litellm.proxy.lens.analysis import Clusters, Observation, cluster_batches, observation_batches
|
||||
|
||||
observations: Final = tuple(
|
||||
Observation(
|
||||
|
|
@ -571,7 +571,7 @@ async def test_thousands_of_matching_runs_keep_all_members_without_a_growing_mod
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grouping_preserves_observations_omitted_by_model() -> None:
|
||||
from litellm.proxy.engine.analysis import merge_candidates
|
||||
from litellm.proxy.lens.analysis import merge_candidates
|
||||
|
||||
original: Final = Candidate(
|
||||
check_id="retries", title="Unrecovered failure", hypothesis="Timeout", execution_ids=("run",)
|
||||
|
|
@ -587,7 +587,7 @@ async def test_grouping_preserves_observations_omitted_by_model() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grouping_repairs_duplicate_members_before_creating_findings() -> None:
|
||||
from litellm.proxy.engine.analysis import Clusters, merge_candidates
|
||||
from litellm.proxy.lens.analysis import Clusters, merge_candidates
|
||||
|
||||
original: Final = Candidate(
|
||||
check_id="retries", title="Unrecovered failure", hypothesis="Timeout", execution_ids=("run",)
|
||||
|
|
@ -609,7 +609,7 @@ async def test_grouping_repairs_duplicate_members_before_creating_findings() ->
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_keeps_original_ids_in_per_run_assessments() -> None:
|
||||
from litellm.proxy.engine.analysis import analyze_sample
|
||||
from litellm.proxy.lens.analysis import analyze_sample
|
||||
|
||||
execution: Final = Execution(
|
||||
id="opaque-original-id",
|
||||
|
|
@ -636,7 +636,7 @@ async def test_review_keeps_original_ids_in_per_run_assessments() -> None:
|
|||
async def progress(_stage: str, _coverage: Coverage) -> None:
|
||||
pass
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await analyze_sample(claim, Sample(executions=(execution,), eligible=1), read, model, progress)
|
||||
assert result.assessments[0].execution_id == execution.id
|
||||
assert not result.assessments[0].cannot_assess
|
||||
|
|
@ -678,7 +678,7 @@ async def test_investigation_context_accounts_for_metadata_on_thousands_of_short
|
|||
async def read(_identity: str, _cursor: str, _offset: int) -> ExecutionContent:
|
||||
pytest.fail("No read was requested")
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await investigate(
|
||||
claim,
|
||||
Candidate(
|
||||
|
|
@ -696,7 +696,7 @@ async def test_investigation_context_accounts_for_metadata_on_thousands_of_short
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_read_does_not_make_supported_review_unknown() -> None:
|
||||
from litellm.proxy.engine.analysis import Observation, SpanRead, TraceReview
|
||||
from litellm.proxy.lens.analysis import Observation, SpanRead, TraceReview
|
||||
|
||||
execution: Final = Execution(
|
||||
id="run", source="traces", trace_id="t", team_id="", name="task", start_time="", span_count=1
|
||||
|
|
@ -720,7 +720,7 @@ async def test_completed_read_does_not_make_supported_review_unknown() -> None:
|
|||
content=TraceReview(reads=(SpanRead(span_id="s"),), observations=(observation,)).model_dump_json(), cost=0
|
||||
)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await extract(claim, execution, read, model)
|
||||
assert result.observations == (observation,)
|
||||
assert not result.cannot_assess and not result.partial
|
||||
|
|
@ -770,7 +770,7 @@ async def test_echoed_feedback_page_does_not_skip_requested_evidence() -> None:
|
|||
cost=0,
|
||||
)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await extract(claim, execution, read, model)
|
||||
assert tuple(requests.get_nowait() for _ in range(requests.qsize())) == (0, 1)
|
||||
assert len(result.observations) == 1
|
||||
|
|
@ -797,7 +797,7 @@ async def test_empty_navigation_requires_a_final_decision(action: str) -> None:
|
|||
return ModelResult(content='{"action":"inconclusive"}', cost=0)
|
||||
return ModelResult(content=json.dumps({"action": action, "page": 999, "execution_id": "run"}), cost=0)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
result: Final = await investigate(
|
||||
claim,
|
||||
Candidate(check_id="retries", title="Timeout", hypothesis="Failed", execution_ids=("run",)),
|
||||
|
|
@ -812,20 +812,20 @@ async def test_empty_navigation_requires_a_final_decision(action: str) -> None:
|
|||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("phase", ("extract", "investigate"))
|
||||
async def test_large_feedback_history_is_accessible_without_overflowing_context(phase: str) -> None:
|
||||
from litellm.proxy.engine.state import merge_finding
|
||||
from litellm.proxy.lens.state import merge_finding
|
||||
|
||||
execution: Final = Execution(
|
||||
id="run", source="traces", trace_id="t", team_id="", name="task", start_time="", span_count=1
|
||||
)
|
||||
part: Final = TracePart(execution_id="run", span_id="span", name="task", kind="agent", content="timeout")
|
||||
accepted: Final = merge_finding(engine(), finding("run"), 1, NOW)
|
||||
accepted: Final = merge_finding(lens(), finding("run"), 1, NOW)
|
||||
prior: Final = tuple(
|
||||
accepted.model_copy(
|
||||
update=MappingProxyType({"id": str(i), "status": "dismissed", "reason": f"Accepted-{i}: " + "x" * 1900})
|
||||
)
|
||||
for i in range(60)
|
||||
)
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=prior)
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=prior)
|
||||
pages: Final = SimpleQueue[int]()
|
||||
|
||||
async def read(_identity: str, _cursor: str, _offset: int) -> ExecutionContent:
|
||||
|
|
@ -865,7 +865,7 @@ async def test_large_feedback_history_is_accessible_without_overflowing_context(
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_registry_reconciles_patterns_split_across_pages() -> None:
|
||||
from litellm.proxy.engine.analysis import Clusters, Observation, cluster_batches
|
||||
from litellm.proxy.lens.analysis import Clusters, Observation, cluster_batches
|
||||
|
||||
observations: Final = tuple(
|
||||
Observation(
|
||||
|
|
@ -902,7 +902,7 @@ async def test_final_registry_reconciles_patterns_split_across_pages() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_distinct_patterns_are_consolidated_in_batches_without_losing_runs() -> None:
|
||||
from litellm.proxy.engine.analysis import Observation, cluster_batches, observation_batches
|
||||
from litellm.proxy.lens.analysis import Observation, cluster_batches, observation_batches
|
||||
|
||||
observations: Final = tuple(
|
||||
Observation(
|
||||
|
|
@ -930,7 +930,7 @@ async def test_distinct_patterns_are_consolidated_in_batches_without_losing_runs
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_candidate_response_preserves_other_findings_and_reports_inconclusive() -> None:
|
||||
from litellm.proxy.engine.analysis import investigate_candidates
|
||||
from litellm.proxy.lens.analysis import investigate_candidates
|
||||
|
||||
execution: Final = Execution(
|
||||
id="run", source="traces", trace_id="t", team_id="", name="task", start_time="", span_count=1
|
||||
|
|
@ -954,7 +954,7 @@ async def test_invalid_candidate_response_preserves_other_findings_and_reports_i
|
|||
async def progress(_stage: str, coverage: Coverage) -> None:
|
||||
counts.put(coverage.inconclusive)
|
||||
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
results: Final = tuple(
|
||||
[
|
||||
result
|
||||
|
|
@ -4,7 +4,7 @@ import pytest
|
|||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.engine.endpoints import user_scope
|
||||
from litellm.proxy.lens.endpoints import user_scope
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -27,10 +27,10 @@ def test_admin_can_configure_lens_and_viewer_can_only_read() -> None:
|
|||
|
||||
@pytest.mark.parametrize("identity", ("not-an-execution", "W10=", "WyJvdGhlciIsICIiLCAiaWQiXQ=="))
|
||||
def test_invalid_explicit_execution_ids_are_rejected(identity: str) -> None:
|
||||
from litellm.proxy.engine.endpoints import validate_selection
|
||||
from tests.unit.proxy.engine.test_state import engine
|
||||
from litellm.proxy.lens.endpoints import validate_selection
|
||||
from tests.unit.proxy.lens.test_state import lens
|
||||
|
||||
settings: Final = engine().settings.model_copy(update={"execution_ids": (identity,)})
|
||||
settings: Final = lens().settings.model_copy(update={"execution_ids": (identity,)})
|
||||
with pytest.raises(HTTPException) as error:
|
||||
validate_selection(settings)
|
||||
assert error.value.status_code == 422
|
||||
|
|
@ -38,8 +38,8 @@ def test_invalid_explicit_execution_ids_are_rejected(identity: str) -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incompatible_worker_is_rejected_before_claiming_work() -> None:
|
||||
from litellm.proxy.engine.endpoints import claim
|
||||
from tests.unit.proxy.engine.test_state import worker
|
||||
from litellm.proxy.lens.endpoints import claim
|
||||
from tests.unit.proxy.lens.test_state import worker
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await claim(worker(), protocol_version=1)
|
||||
|
|
@ -2,18 +2,18 @@ from typing import Final
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.engine.inference import Deployment, DeploymentParams, completion_charge, quote
|
||||
from litellm.proxy.lens.inference import Deployment, DeploymentParams, completion_charge, quote
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
|
||||
def test_custom_priced_model_charges_reported_tokens() -> None:
|
||||
deployment: Final = Deployment(
|
||||
litellm_params=DeploymentParams(
|
||||
model="openai/engine-test", input_cost_per_token=0.001, output_cost_per_token=0.002
|
||||
model="openai/lens-test", input_cost_per_token=0.001, output_cost_per_token=0.002
|
||||
)
|
||||
)
|
||||
response: Final = ModelResponse(
|
||||
model="engine-test", usage={"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}
|
||||
model="lens-test", usage={"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}
|
||||
)
|
||||
assert completion_charge((deployment,), response, 10) == pytest.approx(0.04)
|
||||
assert quote((deployment,), "hello") > 0.04
|
||||
|
|
@ -4,11 +4,11 @@ from typing import Final
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.engine.models import Scope, MetadataFilter
|
||||
from litellm.proxy.engine.sources import SourceReader
|
||||
from tests.unit.proxy.engine.test_state import engine
|
||||
from litellm.proxy.lens.models import Scope, MetadataFilter
|
||||
from litellm.proxy.lens.sources import SourceReader
|
||||
from tests.unit.proxy.lens.test_state import lens
|
||||
|
||||
from litellm.proxy.engine.sources import execution_id, parse_execution
|
||||
from litellm.proxy.lens.sources import execution_id, parse_execution
|
||||
|
||||
|
||||
def test_same_trace_id_from_different_keys_is_a_distinct_execution() -> None:
|
||||
|
|
@ -57,7 +57,7 @@ async def test_sample_never_returns_authentication_attributes() -> None:
|
|||
]
|
||||
|
||||
reader: Final = SourceReader(StorageResponse())
|
||||
sample: Final = await reader.sample(Scope(team_id="alpha"), engine().settings, 1, 2)
|
||||
sample: Final = await reader.sample(Scope(team_id="alpha"), lens().settings, 1, 2)
|
||||
assert sample.executions[0].metadata == (MetadataFilter(key="environment", value="production"),)
|
||||
assert "opaque-oauth-bearer" not in sample.model_dump_json()
|
||||
assert sample.eligible == 1
|
||||
|
|
@ -3,17 +3,17 @@ from typing import Final
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.engine.models import Check, Engine, EngineSettings, Evidence, FindingDraft, Scope, Worker
|
||||
from litellm.proxy.engine.state import can_access, claim_job, current_job, merge_finding, queue_job, renew_budget
|
||||
from litellm.proxy.lens.models import Check, Lens, LensSettings, Evidence, FindingDraft, Scope, Worker
|
||||
from litellm.proxy.lens.state import can_access, claim_job, current_job, merge_finding, queue_job, renew_budget
|
||||
|
||||
NOW: Final = datetime(2026, 1, 15, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def engine() -> Engine:
|
||||
return Engine(
|
||||
id="engine",
|
||||
def lens() -> Lens:
|
||||
return Lens(
|
||||
id="lens",
|
||||
scope=Scope(team_id="alpha"),
|
||||
settings=EngineSettings(
|
||||
settings=LensSettings(
|
||||
name="Research", model="analysis", checks=(Check(id="retries", instruction="Find unrecovered retries"),)
|
||||
),
|
||||
created_at=NOW,
|
||||
|
|
@ -50,7 +50,7 @@ def test_scope_never_crosses_another_team_or_key(viewer: Scope, target: Scope, a
|
|||
|
||||
|
||||
def test_queue_is_idempotent_and_settings_are_frozen() -> None:
|
||||
original: Final = engine()
|
||||
original: Final = lens()
|
||||
queued: Final = queue_job(original, NOW, "job")
|
||||
edited: Final = queued.model_copy(
|
||||
update={"settings": original.settings.model_copy(update={"model": "replacement"})}
|
||||
|
|
@ -65,7 +65,7 @@ def test_queue_is_idempotent_and_settings_are_frozen() -> None:
|
|||
|
||||
|
||||
def test_one_off_overrides_do_not_change_saved_monitoring_settings() -> None:
|
||||
original: Final = engine()
|
||||
original: Final = lens()
|
||||
override: Final = original.settings.model_copy(
|
||||
update={"sample_percent": 10, "sample_size": None, "concurrency": 3, "lookback_hours": 72}
|
||||
)
|
||||
|
|
@ -79,7 +79,7 @@ def test_one_off_overrides_do_not_change_saved_monitoring_settings() -> None:
|
|||
|
||||
|
||||
def test_behavior_description_is_sufficient_without_separate_checks() -> None:
|
||||
settings: Final = EngineSettings(name="Behavior", model="analysis", context="Answer using cited sources")
|
||||
settings: Final = LensSettings(name="Behavior", model="analysis", context="Answer using cited sources")
|
||||
assert tuple(c.id for c in settings.analysis_checks) == ("expected_behavior",)
|
||||
assert settings.sample_size is None
|
||||
assert settings.sample_percent == 100
|
||||
|
|
@ -92,11 +92,11 @@ def test_invalid_selection_and_parallelism_are_rejected(field: str, value: int)
|
|||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
EngineSettings.model_validate({**engine().settings.model_dump(), field: value})
|
||||
LensSettings.model_validate({**lens().settings.model_dump(), field: value})
|
||||
|
||||
|
||||
def test_lease_prevents_double_claim_and_expires_with_bounded_retries() -> None:
|
||||
queued: Final = queue_job(engine(), NOW, "job")
|
||||
queued: Final = queue_job(lens(), NOW, "job")
|
||||
first: Final = claim_job(queued, worker(), NOW)
|
||||
assert claim_job(first, worker(identity="second"), NOW) is first
|
||||
assert claim_job(first, worker(team="beta"), NOW + timedelta(minutes=6)) is first
|
||||
|
|
@ -110,9 +110,9 @@ def test_lease_prevents_double_claim_and_expires_with_bounded_retries() -> None:
|
|||
|
||||
|
||||
def test_replaying_evidence_does_not_reopen_but_new_occurrence_does() -> None:
|
||||
from litellm.proxy.engine.state import snapshot_finding
|
||||
from litellm.proxy.lens.state import snapshot_finding
|
||||
|
||||
original: Final = engine()
|
||||
original: Final = lens()
|
||||
resolved: Final = merge_finding(original, finding("run1"), 1, NOW).model_copy(update={"status": "resolved"})
|
||||
reviewed: Final = original.model_copy(update={"findings": (resolved,)})
|
||||
assert merge_finding(reviewed, finding("run1"), 1, NOW).status == "resolved"
|
||||
|
|
@ -138,7 +138,7 @@ def test_replaying_evidence_does_not_reopen_but_new_occurrence_does() -> None:
|
|||
|
||||
|
||||
def test_monthly_budget_renews_without_erasing_job_costs() -> None:
|
||||
spent: Final = queue_job(engine(), NOW, "job").model_copy(update={"spent": 12})
|
||||
spent: Final = queue_job(lens(), NOW, "job").model_copy(update={"spent": 12})
|
||||
renewed: Final = renew_budget(spent, datetime(2026, 2, 1, tzinfo=timezone.utc))
|
||||
assert renewed.spent == 0
|
||||
assert renewed.jobs == spent.jobs
|
||||
|
|
@ -147,7 +147,7 @@ def test_monthly_budget_renews_without_erasing_job_costs() -> None:
|
|||
|
||||
@pytest.mark.parametrize("hours", (24, 168, 720))
|
||||
def test_every_scan_uses_the_configured_lookback_window(hours: int) -> None:
|
||||
original: Final = engine()
|
||||
original: Final = lens()
|
||||
configured: Final = original.model_copy(
|
||||
update={"settings": original.settings.model_copy(update={"lookback_hours": hours})}
|
||||
)
|
||||
|
|
@ -159,15 +159,15 @@ def test_every_scan_uses_the_configured_lookback_window(hours: int) -> None:
|
|||
|
||||
def test_finding_keeps_uncertainty_separate_from_the_main_summary() -> None:
|
||||
draft: Final = finding("run1").model_copy(update={"limitation": "The final response was not recorded."})
|
||||
saved: Final = merge_finding(engine(), draft, 1, NOW)
|
||||
saved: Final = merge_finding(lens(), draft, 1, NOW)
|
||||
assert saved.limitation == draft.limitation
|
||||
assert saved.description == draft.description
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interval", (1, 2, 37, 90, 10080))
|
||||
def test_custom_schedule_does_not_overlap_an_active_scan(interval: int) -> None:
|
||||
original: Final = engine()
|
||||
settings: Final = EngineSettings.model_validate({**original.settings.model_dump(), "interval_minutes": interval})
|
||||
original: Final = lens()
|
||||
settings: Final = LensSettings.model_validate({**original.settings.model_dump(), "interval_minutes": interval})
|
||||
configured: Final = original.model_copy(update={"settings": settings})
|
||||
running: Final = claim_job(queue_job(configured, NOW, "first"), worker(), NOW)
|
||||
assert queue_job(running, NOW + timedelta(minutes=interval), "second") is running
|
||||
|
|
@ -178,13 +178,13 @@ def test_invalid_schedule_is_rejected(interval: float) -> None:
|
|||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
EngineSettings.model_validate({**engine().settings.model_dump(), "interval_minutes": interval})
|
||||
LensSettings.model_validate({**lens().settings.model_dump(), "interval_minutes": interval})
|
||||
|
||||
|
||||
def test_batch_snapshot_keeps_feedback_identity_and_only_current_evidence() -> None:
|
||||
from litellm.proxy.engine.state import snapshot_finding
|
||||
from litellm.proxy.lens.state import snapshot_finding
|
||||
|
||||
original: Final = engine()
|
||||
original: Final = lens()
|
||||
dismissed: Final = merge_finding(original, finding("old-run"), 1, NOW).model_copy(
|
||||
update={"status": "dismissed", "reason": "Expected recovery"}
|
||||
)
|
||||
|
|
@ -204,9 +204,9 @@ def test_batch_snapshot_keeps_feedback_identity_and_only_current_evidence() -> N
|
|||
|
||||
@pytest.mark.parametrize("explicit_reference", (False, True))
|
||||
def test_issue_and_pattern_with_same_title_keep_independent_feedback(explicit_reference: bool) -> None:
|
||||
from litellm.proxy.engine.state import snapshot_finding
|
||||
from litellm.proxy.lens.state import snapshot_finding
|
||||
|
||||
original: Final = engine()
|
||||
original: Final = lens()
|
||||
issue: Final = merge_finding(original, finding("old"), 1, NOW).model_copy(
|
||||
update={"status": "dismissed", "reason": "Expected retry"}
|
||||
)
|
||||
|
|
@ -228,7 +228,7 @@ def test_issue_and_pattern_with_same_title_keep_independent_feedback(explicit_re
|
|||
def test_legacy_finding_identity_preserves_feedback_only_for_same_kind_and_check() -> None:
|
||||
import hashlib
|
||||
|
||||
original: Final = engine()
|
||||
original: Final = lens()
|
||||
draft: Final = finding("old")
|
||||
legacy_id: Final = hashlib.sha256(f"{original.id}:{draft.check_id}:{draft.title.lower()}".encode()).hexdigest()[:24]
|
||||
legacy: Final = merge_finding(original, draft, 1, NOW).model_copy(
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import json
|
||||
from typing import Final
|
||||
|
||||
from litellm.proxy.engine.models import Evidence, TracePart
|
||||
from litellm.proxy.engine.trace_store import trace_store
|
||||
from litellm.proxy.lens.models import Evidence, TracePart
|
||||
from litellm.proxy.lens.trace_store import trace_store
|
||||
|
||||
|
||||
def test_trace_store_pages_large_payloads_and_recovers_exact_evidence() -> None:
|
||||
|
|
@ -4,7 +4,7 @@ from typing import Final
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.engine.models import (
|
||||
from litellm.proxy.lens.models import (
|
||||
Claim,
|
||||
Execution,
|
||||
ExecutionContent,
|
||||
|
|
@ -14,9 +14,9 @@ from litellm.proxy.engine.models import (
|
|||
Sample,
|
||||
TracePart,
|
||||
)
|
||||
from litellm.proxy.engine.state import queue_job
|
||||
from litellm.proxy.engine.worker import EngineWorker
|
||||
from tests.unit.proxy.engine.test_state import NOW, engine
|
||||
from litellm.proxy.lens.state import queue_job
|
||||
from litellm.proxy.lens.worker import LensWorker
|
||||
from tests.unit.proxy.lens.test_state import NOW, lens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -39,7 +39,7 @@ async def test_model_retries_transient_failures_but_not_budget_or_revocation(fai
|
|||
delays.put(delay)
|
||||
|
||||
async with httpx.AsyncClient(base_url="https://proxy.test", transport=httpx.MockTransport(handle)) as client:
|
||||
worker: Final = EngineWorker(client, sleep=sleep)
|
||||
worker: Final = LensWorker(client, sleep=sleep)
|
||||
if failure in (402, 409, 401):
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await worker.model_request("/model", ModelRequest(purpose="extract", prompt="review"))
|
||||
|
|
@ -64,7 +64,7 @@ async def test_transient_retries_are_bounded() -> None:
|
|||
|
||||
async with httpx.AsyncClient(base_url="https://proxy.test", transport=httpx.MockTransport(handle)) as client:
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await EngineWorker(client, sleep=sleep).model_request(
|
||||
await LensWorker(client, sleep=sleep).model_request(
|
||||
"/model", ModelRequest(purpose="extract", prompt="review")
|
||||
)
|
||||
assert attempts.qsize() == 3
|
||||
|
|
@ -74,17 +74,17 @@ async def test_transient_retries_are_bounded() -> None:
|
|||
@pytest.mark.asyncio
|
||||
async def test_idle_worker_does_not_start_an_analysis() -> None:
|
||||
def handle(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/engine/worker/claim"
|
||||
assert request.url.path == "/lens/worker/claim"
|
||||
return httpx.Response(200, content="null")
|
||||
|
||||
async with httpx.AsyncClient(base_url="https://proxy.test", transport=httpx.MockTransport(handle)) as client:
|
||||
assert await EngineWorker(client).run_once() is False
|
||||
assert await LensWorker(client).run_once() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("model_status", (200, 402, 503))
|
||||
async def test_worker_reads_claimed_activity_and_reports_analysis_or_failure(model_status: int) -> None:
|
||||
claim: Final = Claim(engine_id="engine", job=queue_job(engine(), NOW, "job").jobs[0], findings=())
|
||||
claim: Final = Claim(lens_id="lens", job=queue_job(lens(), NOW, "job").jobs[0], findings=())
|
||||
execution: Final = Execution(
|
||||
id="run", source="traces", trace_id="trace", team_id="alpha", name="review", start_time="", span_count=1
|
||||
)
|
||||
|
|
@ -97,28 +97,28 @@ async def test_worker_reads_claimed_activity_and_reports_analysis_or_failure(mod
|
|||
|
||||
def handle(request: httpx.Request) -> httpx.Response:
|
||||
match request.url.path:
|
||||
case "/engine/worker/claim":
|
||||
case "/lens/worker/claim":
|
||||
return httpx.Response(200, json=claim.model_dump(mode="json"))
|
||||
case "/engine/worker/engine/job/sample":
|
||||
case "/lens/worker/lens/job/sample":
|
||||
return httpx.Response(200, json=sample.model_dump(mode="json"))
|
||||
case "/engine/worker/engine/job/content":
|
||||
case "/lens/worker/lens/job/content":
|
||||
assert request.url.params["execution_id"] == execution.id
|
||||
return httpx.Response(200, json=content.model_dump(mode="json"))
|
||||
case "/engine/worker/engine/job/model":
|
||||
case "/lens/worker/lens/job/model":
|
||||
return httpx.Response(
|
||||
model_status,
|
||||
json=ModelResult(content='{"observations":[],"cannot_assess":false}', cost=0.01).model_dump(),
|
||||
)
|
||||
case "/engine/worker/engine/job/progress":
|
||||
case "/lens/worker/lens/job/progress":
|
||||
return httpx.Response(200, json=True)
|
||||
case "/engine/worker/engine/job/result":
|
||||
case "/lens/worker/lens/job/result":
|
||||
saved.put(Result.model_validate_json(request.content))
|
||||
return httpx.Response(200, json=True)
|
||||
case _:
|
||||
pytest.fail(f"Unexpected analyzer request: {request.url.path}")
|
||||
|
||||
async with httpx.AsyncClient(base_url="https://proxy.test", transport=httpx.MockTransport(handle)) as client:
|
||||
assert await EngineWorker(client).run_once() is True
|
||||
assert await LensWorker(client).run_once() is True
|
||||
result: Final = saved.get_nowait()
|
||||
assert saved.empty()
|
||||
if model_status == 200:
|
||||
|
|
@ -7,7 +7,7 @@ import { apiClient } from "@/components/networking";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TracePanel } from "./TracePanel";
|
||||
import { type Sample, type Settings, runTime, durationLabel } from "./engineData";
|
||||
import { type Sample, type Settings, runTime, durationLabel } from "./lensData";
|
||||
|
||||
import { DurationInput } from "./DurationInput";
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ export function ActivityScope({
|
|||
const valid = validWindow && validSampling && validFilters;
|
||||
const load = (selection: ActivitySelection, pageOffset = 0) => {
|
||||
const { lookback_hours, ...selectionSettings } = selection;
|
||||
return apiClient.post<Sample>("/engine/preview/sample", {
|
||||
return apiClient.post<Sample>("/lens/preview/sample", {
|
||||
accessToken,
|
||||
body: {
|
||||
offset: pageOffset,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { analysisElapsed, analysisProgress, nextCheckStatus, type Engine, type Job } from "./engineData";
|
||||
import { analysisElapsed, analysisProgress, nextCheckStatus, type Lens, type Job } from "./lensData";
|
||||
|
||||
const steps = ["Review runs", "Find patterns", "Check evidence"];
|
||||
|
||||
export function EngineProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
||||
export function LensProgress({ job, onCancel }: { job: Job; onCancel?: () => void }) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
|
|
@ -69,13 +69,13 @@ export function EngineProgress({ job, onCancel }: { job: Job; onCancel?: () => v
|
|||
);
|
||||
}
|
||||
|
||||
export function NextCheck({ engine }: { engine: Engine }) {
|
||||
export function NextCheck({ lens }: { lens: Lens }) {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 15000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const label = nextCheckStatus(engine, now);
|
||||
const label = nextCheckStatus(lens, now);
|
||||
if (!label) return null;
|
||||
return <p className="mt-1 text-xs text-muted-foreground">{label}</p>;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { useState } from "react";
|
|||
import { ArrowUpRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RunList } from "./ActivityScope";
|
||||
import type { Job } from "./engineData";
|
||||
import type { Job } from "./lensData";
|
||||
|
||||
function assessmentLabel(assessment: Job["assessments"][number] | undefined): string {
|
||||
if (!assessment) return "Not reviewed";
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { fireEvent, screen } from "@testing-library/react";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { EngineSetup } from "./EngineSetup";
|
||||
import { LensSetup } from "./LensSetup";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import type { Settings } from "./engineData";
|
||||
import type { Settings } from "./lensData";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ apiClient: { post: vi.fn() } }));
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ const settings: Settings = {
|
|||
],
|
||||
};
|
||||
|
||||
describe("Engine setup", () => {
|
||||
describe("Lens setup", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiClient.post).mockReset();
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ eligible: 0, executions: [] });
|
||||
|
|
@ -39,7 +39,7 @@ describe("Engine setup", () => {
|
|||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<EngineSetup initial={settings} models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={save} />,
|
||||
<LensSetup initial={settings} models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={save} />,
|
||||
);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Specific checks (optional)" }), {
|
||||
target: { value: "Find incomplete reports\nFind repeated searches" },
|
||||
|
|
@ -52,7 +52,7 @@ describe("Engine setup", () => {
|
|||
|
||||
it("rejects invalid metadata before reviewing the selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EngineSetup models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />);
|
||||
renderWithProviders(<LensSetup models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={vi.fn()} />);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Research" } });
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add condition" }));
|
||||
|
|
@ -82,7 +82,7 @@ describe("Engine setup", () => {
|
|||
}
|
||||
: { eligible: 0, executions: [] };
|
||||
});
|
||||
renderWithProviders(<EngineSetup models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={save} />);
|
||||
renderWithProviders(<LensSetup models={["analysis"]} accessToken="test" onClose={vi.fn()} onSave={save} />);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Research" } });
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add condition" }));
|
||||
|
|
@ -106,7 +106,7 @@ it("searches providers and saves custom history and schedule values", async () =
|
|||
const user = userEvent.setup();
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<EngineSetup
|
||||
<LensSetup
|
||||
initial={settings}
|
||||
models={["review", "other"]}
|
||||
modelDetails={[
|
||||
|
|
@ -20,12 +20,12 @@ import {
|
|||
starterQuestions,
|
||||
type AnalysisModelInfo,
|
||||
type Settings,
|
||||
} from "./engineData";
|
||||
} from "./lensData";
|
||||
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { DurationInput } from "./DurationInput";
|
||||
|
||||
export function EngineSetup({
|
||||
export function LensSetup({
|
||||
initial,
|
||||
mode = initial ? "edit" : "new",
|
||||
models,
|
||||
|
|
@ -3,8 +3,8 @@ import userEvent from "@testing-library/user-event";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { EngineView } from "./EngineView";
|
||||
import { nextCheckStatus, type Engine, type Finding } from "./engineData";
|
||||
import { LensView } from "./LensView";
|
||||
import { nextCheckStatus, type Lens, type Finding } from "./lensData";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ apiClient: { get: vi.fn(), post: vi.fn() }, proxyBaseUrl: "" }));
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ const issue: Finding = {
|
|||
kind: "issue",
|
||||
priority: "high",
|
||||
};
|
||||
const engine: Engine = {
|
||||
const lens: Lens = {
|
||||
version: 0,
|
||||
spent: 0,
|
||||
id: "lens",
|
||||
|
|
@ -134,15 +134,15 @@ describe("Lens findings and runs", () => {
|
|||
beforeEach(() => {
|
||||
vi.mocked(apiClient.get).mockReset();
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
if (path === "/engine") return { engines: [engine], workers: [], tracing_enabled: true };
|
||||
if (path === "/engine/lens/runs") return engine.jobs;
|
||||
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
|
||||
if (path === "/lens/lens/runs") return lens.jobs;
|
||||
return { data: [] };
|
||||
});
|
||||
});
|
||||
|
||||
it("separates patterns from issues and reveals original evidence only when requested", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EngineView accessToken="test" readOnly />);
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
expect(await screen.findByText("Review used the wrong defect rate")).toBeInTheDocument();
|
||||
expect(screen.queryByText(pattern.title)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Patterns (1)" }));
|
||||
|
|
@ -159,7 +159,7 @@ describe("Lens findings and runs", () => {
|
|||
|
||||
it("shows the actual frozen run selection in the Runs tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EngineView accessToken="test" readOnly />);
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Runs" }));
|
||||
expect(screen.getByText("Release-42")).toBeInTheDocument();
|
||||
expect(screen.getByText("trace-42")).toBeInTheDocument();
|
||||
|
|
@ -170,27 +170,27 @@ describe("Lens findings and runs", () => {
|
|||
it("shows the actual next schedule and avoids a stale countdown during active scans", () => {
|
||||
const now = Date.parse("2026-09-30T10:00:00Z");
|
||||
const monitoring = {
|
||||
...engine,
|
||||
settings: { ...engine.settings, enabled: true },
|
||||
...lens,
|
||||
settings: { ...lens.settings, enabled: true },
|
||||
next_run_at: "2026-09-30T10:12:00Z",
|
||||
};
|
||||
expect(nextCheckStatus(monitoring, now)).toContain("in 12 minutes");
|
||||
expect(nextCheckStatus(monitoring, now + 12 * 60000)).toBe("Due now · waiting for an analyzer");
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...engine.jobs[0], status: "running" }] }, now)).toBe(
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...lens.jobs[0], status: "running" }] }, now)).toBe(
|
||||
"Next check scheduled after this scan finishes",
|
||||
);
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...engine.jobs[0], status: "queued" }] }, now)).toBe(
|
||||
expect(nextCheckStatus({ ...monitoring, jobs: [{ ...lens.jobs[0], status: "queued" }] }, now)).toBe(
|
||||
"Waiting for an analyzer",
|
||||
);
|
||||
expect(nextCheckStatus(engine, now)).toBeNull();
|
||||
expect(nextCheckStatus(lens, now)).toBeNull();
|
||||
});
|
||||
|
||||
it("runs saved settings immediately without opening setup", async () => {
|
||||
testQueryClient.clear();
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
if (path === "/engine")
|
||||
if (path === "/lens")
|
||||
return {
|
||||
engines: [engine],
|
||||
lenses: [lens],
|
||||
tracing_enabled: true,
|
||||
workers: [
|
||||
{
|
||||
|
|
@ -198,29 +198,29 @@ it("runs saved settings immediately without opening setup", async () => {
|
|||
name: "Worker",
|
||||
revoked: false,
|
||||
analysis_key_id: "a".repeat(64),
|
||||
scope: engine.scope,
|
||||
scope: lens.scope,
|
||||
last_seen: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
if (path === "/engine/lens/runs") return engine.jobs;
|
||||
if (path === "/lens/lens/runs") return lens.jobs;
|
||||
return { data: [] };
|
||||
});
|
||||
vi.mocked(apiClient.post).mockResolvedValue(engine);
|
||||
vi.mocked(apiClient.post).mockResolvedValue(lens);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EngineView accessToken="test" />);
|
||||
renderWithProviders(<LensView accessToken="test" />);
|
||||
await user.click(await screen.findByRole("button", { name: "Run now" }));
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/engine/lens/runs", { accessToken: "test", body: {} });
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/lens/lens/runs", { accessToken: "test", body: {} });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("guides a first-time administrator into worker connection and lens setup", async () => {
|
||||
testQueryClient.clear();
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) =>
|
||||
path === "/engine" ? { engines: [], workers: [], tracing_enabled: true } : { data: [] },
|
||||
path === "/lens" ? { lenses: [], workers: [], tracing_enabled: true } : { data: [] },
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EngineView accessToken="test" />);
|
||||
renderWithProviders(<LensView accessToken="test" />);
|
||||
const guide = within(await screen.findByRole("region", { name: "Understand what your agents are doing" }));
|
||||
expect(guide.getByRole("link", { name: "View logs" })).toHaveAttribute("href", "/ui/logs/");
|
||||
await user.click(guide.getByRole("button", { name: "Connect analyzer" }));
|
||||
|
|
@ -234,20 +234,20 @@ it("guides a first-time administrator into worker connection and lens setup", as
|
|||
it("opens the saved results of an older batch", async () => {
|
||||
testQueryClient.clear();
|
||||
const older = {
|
||||
...engine.jobs[0],
|
||||
...lens.jobs[0],
|
||||
id: "older",
|
||||
created_at: "2026-09-29T10:00:00Z",
|
||||
finished_at: "2026-09-29T10:02:13Z",
|
||||
findings: [{ ...issue, title: "Earlier batch finding" }],
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path) => {
|
||||
if (path === "/engine") return { engines: [engine], workers: [], tracing_enabled: true };
|
||||
if (path === "/engine/lens/runs") return [engine.jobs[0], older];
|
||||
if (path === "/engine/lens/runs/older") return older;
|
||||
if (path === "/lens") return { lenses: [lens], workers: [], tracing_enabled: true };
|
||||
if (path === "/lens/lens/runs") return [lens.jobs[0], older];
|
||||
if (path === "/lens/lens/runs/older") return older;
|
||||
return { data: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EngineView accessToken="test" readOnly />);
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
await screen.findByRole("option", { name: `${new Date(older.created_at).toLocaleString()} · completed` });
|
||||
await user.selectOptions(screen.getByRole("combobox", { name: "Investigation batch" }), "older");
|
||||
expect(await screen.findByText("Earlier batch finding")).toBeVisible();
|
||||
|
|
@ -264,15 +264,15 @@ it("reads request content from the beginning after its abbreviated preview", asy
|
|||
testQueryClient.clear();
|
||||
const requestId = btoa(JSON.stringify(["requests", "", "request-1"]));
|
||||
const job = {
|
||||
...engine.jobs[0],
|
||||
...lens.jobs[0],
|
||||
sample: {
|
||||
eligible: 1,
|
||||
executions: [{ ...engine.jobs[0].sample!.executions[0], id: requestId, source: "requests" as const }],
|
||||
executions: [{ ...lens.jobs[0].sample!.executions[0], id: requestId, source: "requests" as const }],
|
||||
},
|
||||
};
|
||||
vi.mocked(apiClient.get).mockImplementation(async (path, options) => {
|
||||
if (path === "/engine") return { engines: [{ ...engine, jobs: [job] }], workers: [], tracing_enabled: true };
|
||||
if (path === "/engine/lens/runs") return [job];
|
||||
if (path === "/lens") return { lenses: [{ ...lens, jobs: [job] }], workers: [], tracing_enabled: true };
|
||||
if (path === "/lens/lens/runs") return [job];
|
||||
const offset = options?.query?.offset ?? 0;
|
||||
return {
|
||||
parts: [
|
||||
|
|
@ -285,7 +285,7 @@ it("reads request content from the beginning after its abbreviated preview", asy
|
|||
};
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EngineView accessToken="test" readOnly />);
|
||||
renderWithProviders(<LensView accessToken="test" readOnly />);
|
||||
await user.click(await screen.findByRole("tab", { name: "Runs" }));
|
||||
await user.click(screen.getByRole("button", { name: "Open request" }));
|
||||
expect(await screen.findByText("Abbreviated preview")).toBeVisible();
|
||||
|
|
@ -22,22 +22,22 @@ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/compone
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { TracePanel } from "./TracePanel";
|
||||
import { EngineSetup } from "./EngineSetup";
|
||||
import { LensSetup } from "./LensSetup";
|
||||
import { LensRuns } from "./LensRuns";
|
||||
import { EngineProgress, NextCheck, ScanDuration } from "./EngineProgress";
|
||||
import { LensProgress, NextCheck, ScanDuration } from "./LensProgress";
|
||||
import { WorkerSetup } from "./WorkerSetup";
|
||||
import { LensWelcome } from "./LensWelcome";
|
||||
import {
|
||||
engineStatus,
|
||||
lensStatus,
|
||||
evidenceTarget,
|
||||
sortedFindings,
|
||||
runTime,
|
||||
type Engine,
|
||||
type EngineList,
|
||||
type Lens,
|
||||
type LensList,
|
||||
type Finding,
|
||||
type Settings,
|
||||
type Job,
|
||||
} from "./engineData";
|
||||
} from "./lensData";
|
||||
|
||||
const money = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 3 }).format(n);
|
||||
|
|
@ -50,22 +50,22 @@ function emptyFindingTitle(active: boolean, scanned: boolean) {
|
|||
return scanned ? "No matching findings" : "Ready for the first analysis";
|
||||
}
|
||||
|
||||
export function EngineView({ accessToken, readOnly = false }: { accessToken: string; readOnly?: boolean }) {
|
||||
export function LensView({ accessToken, readOnly = false }: { accessToken: string; readOnly?: boolean }) {
|
||||
const client = useQueryClient();
|
||||
const key = ["engines", accessToken];
|
||||
const key = ["lenses", accessToken];
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => apiClient.get<EngineList>("/engine", { accessToken }),
|
||||
queryFn: () => apiClient.get<LensList>("/lens", { accessToken }),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
const models = useQuery({
|
||||
queryKey: ["engine-models", accessToken],
|
||||
queryKey: ["lens-models", accessToken],
|
||||
queryFn: () => apiClient.get<{ data: { id: string }[] }>("/models", { accessToken }),
|
||||
});
|
||||
const modelDetails = useQuery({
|
||||
queryKey: ["lens-model-details", accessToken],
|
||||
queryFn: () =>
|
||||
apiClient.get<{ data: import("./engineData").AnalysisModelInfo[] }>("/model_group/info", { accessToken }),
|
||||
apiClient.get<{ data: import("./lensData").AnalysisModelInfo[] }>("/model_group/info", { accessToken }),
|
||||
});
|
||||
const [selected, setSelected] = useState<string | null>(() =>
|
||||
typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("lens"),
|
||||
|
|
@ -91,32 +91,31 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [evidence, setEvidence] = useState<{ id: string; span: string } | null>(null);
|
||||
const engines = [...(query.data?.engines ?? [])].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
||||
const showEmpty = !query.isLoading && !query.error && engines.length === 0;
|
||||
const engine = engines.find((e) => e.id === selected) ?? engines[0];
|
||||
const lenses = [...(query.data?.lenses ?? [])].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
||||
const showEmpty = !query.isLoading && !query.error && lenses.length === 0;
|
||||
const lens = lenses.find((e) => e.id === selected) ?? lenses[0];
|
||||
const connected =
|
||||
query.data?.workers?.some(
|
||||
(w) => !w.revoked && w.analysis_key_id && query.dataUpdatedAt - Date.parse(w.last_seen) < 120000,
|
||||
) ?? false;
|
||||
const historyQuery = {
|
||||
queryKey: ["lens-history", engine?.id, historyOffset, accessToken],
|
||||
enabled: !!engine,
|
||||
queryFn: () =>
|
||||
apiClient.get<Job[]>(`/engine/${engine?.id}/runs`, { accessToken, query: { offset: historyOffset } }),
|
||||
queryKey: ["lens-history", lens?.id, historyOffset, accessToken],
|
||||
enabled: !!lens,
|
||||
queryFn: () => apiClient.get<Job[]>(`/lens/${lens?.id}/runs`, { accessToken, query: { offset: historyOffset } }),
|
||||
refetchInterval: 10000,
|
||||
};
|
||||
const history = useQuery(historyQuery);
|
||||
const historical = useQuery({
|
||||
queryKey: ["lens-batch", engine?.id, batchId, accessToken],
|
||||
enabled: !!engine && !["latest", "all"].includes(batchId),
|
||||
queryFn: () => apiClient.get<Job>(`/engine/${engine?.id}/runs/${batchId}`, { accessToken }),
|
||||
queryKey: ["lens-batch", lens?.id, batchId, accessToken],
|
||||
enabled: !!lens && !["latest", "all"].includes(batchId),
|
||||
queryFn: () => apiClient.get<Job>(`/lens/${lens?.id}/runs/${batchId}`, { accessToken }),
|
||||
});
|
||||
const job = ["latest", "all"].includes(batchId) ? engine?.jobs?.[0] : historical.data;
|
||||
const job = ["latest", "all"].includes(batchId) ? lens?.jobs?.[0] : historical.data;
|
||||
const missingSnapshot = job?.status === "completed" && job.findings == null && batchId !== "all";
|
||||
const selectedOutsideHistory = !["latest", "all"].includes(batchId) && !history.data?.some((j) => j.id === batchId);
|
||||
const batchSettings = job?.settings ?? engine?.settings;
|
||||
const batchFindings = (batchId === "all" ? engine?.findings ?? [] : job?.findings ?? []).map((f) => {
|
||||
const feedback = engine?.findings?.find((current) => current.id === f.id);
|
||||
const batchSettings = job?.settings ?? lens?.settings;
|
||||
const batchFindings = (batchId === "all" ? lens?.findings ?? [] : job?.findings ?? []).map((f) => {
|
||||
const feedback = lens?.findings?.find((current) => current.id === f.id);
|
||||
return feedback ? { ...f, status: feedback.status, reason: feedback.reason } : f;
|
||||
});
|
||||
const finding = batchFindings.find((f) => f.id === findingId);
|
||||
|
|
@ -127,12 +126,12 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
};
|
||||
const setupSettings = () => {
|
||||
if (editing === "new") return undefined;
|
||||
if (editing === "duplicate" && engine)
|
||||
return { ...engine.settings, name: `${engine.settings.name} copy`, enabled: false };
|
||||
return engine?.settings;
|
||||
if (editing === "duplicate" && lens)
|
||||
return { ...lens.settings, name: `${lens.settings.name} copy`, enabled: false };
|
||||
return lens?.settings;
|
||||
};
|
||||
const lastCompleted = engine?.jobs?.find((j) => j.status === "completed");
|
||||
const active = engine?.jobs?.find((j) => j.status === "queued" || j.status === "running");
|
||||
const lastCompleted = lens?.jobs?.find((j) => j.status === "completed");
|
||||
const active = lens?.jobs?.find((j) => j.status === "queued" || j.status === "running");
|
||||
const visibleFindings = sortedFindings(
|
||||
batchFindings.filter((f) => (filter === "all" || f.status === filter) && f.kind === kind),
|
||||
);
|
||||
|
|
@ -147,11 +146,11 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
const target = evidence ? evidenceTarget(evidence.id) : null;
|
||||
const [requestOffset, setRequestOffset] = useState(0);
|
||||
const requestEvidence = useQuery({
|
||||
queryKey: ["engine-evidence", engine?.id, evidence?.id, requestOffset, accessToken],
|
||||
enabled: !!engine && target?.source === "requests",
|
||||
queryKey: ["lens-evidence", lens?.id, evidence?.id, requestOffset, accessToken],
|
||||
enabled: !!lens && target?.source === "requests",
|
||||
queryFn: () =>
|
||||
apiClient.get<components["schemas"]["ExecutionContent"]>(
|
||||
`/engine/${engine?.id}/executions/${encodeURIComponent(evidence?.id ?? "")}`,
|
||||
`/lens/${lens?.id}/executions/${encodeURIComponent(evidence?.id ?? "")}`,
|
||||
{ accessToken, query: { offset: requestOffset } },
|
||||
),
|
||||
});
|
||||
|
|
@ -172,9 +171,9 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
}
|
||||
};
|
||||
const save = async (settings: Settings) => {
|
||||
const saved = await apiClient.request<Engine>(
|
||||
const saved = await apiClient.request<Lens>(
|
||||
editing === "edit" ? "PUT" : "POST",
|
||||
editing === "edit" ? `/engine/${engine.id}` : "/engine",
|
||||
editing === "edit" ? `/lens/${lens.id}` : "/lens",
|
||||
{ accessToken, body: settings },
|
||||
);
|
||||
selectLens(saved.id);
|
||||
|
|
@ -182,8 +181,8 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
refresh();
|
||||
};
|
||||
const changeFinding = async (status: Finding["status"]) => {
|
||||
if (!engine || !finding) return;
|
||||
await update(`/engine/${engine.id}/findings/${finding.id}`, { status, reason }, "patch");
|
||||
if (!lens || !finding) return;
|
||||
await update(`/lens/${lens.id}/findings/${finding.id}`, { status, reason }, "patch");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -206,7 +205,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
/>
|
||||
{connected ? "Analyzer connected" : "Set up analysis"}
|
||||
</Button>
|
||||
{engines.length > 0 && (
|
||||
{lenses.length > 0 && (
|
||||
<Button onClick={() => setEditing("new")}>
|
||||
<Plus className="size-4" />
|
||||
New lens
|
||||
|
|
@ -236,29 +235,29 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
onCreate={() => setEditing("new")}
|
||||
/>
|
||||
)}
|
||||
{engine && (
|
||||
{lens && (
|
||||
<div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<nav aria-label="Lenses" className="flex gap-2 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||
{engines.map((e) => (
|
||||
{lenses.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => selectLens(e.id)}
|
||||
aria-current={engine.id === e.id ? "page" : undefined}
|
||||
className={`min-w-44 rounded-lg px-3 py-3 text-left transition-colors ${engine.id === e.id ? "bg-muted" : "hover:bg-muted/50"}`}
|
||||
aria-current={lens.id === e.id ? "page" : undefined}
|
||||
className={`min-w-44 rounded-lg px-3 py-3 text-left transition-colors ${lens.id === e.id ? "bg-muted" : "hover:bg-muted/50"}`}
|
||||
>
|
||||
<span className="block truncate text-sm font-medium">{e.settings.name}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{engineStatus(e, connected)}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{lensStatus(e, connected)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<section className="min-w-0 space-y-5">
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{engine.settings.name}</h2>
|
||||
<h2 className="text-lg font-semibold">{lens.settings.name}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{sourceLabels[engine.settings.source ?? "traces"]} ·{" "}
|
||||
{engine.settings.service || "All accessible activity"}
|
||||
{engine.settings.filters?.length ? ` · ${engine.settings.filters.length} filters` : ""}
|
||||
{sourceLabels[lens.settings.source ?? "traces"]} ·{" "}
|
||||
{lens.settings.service || "All accessible activity"}
|
||||
{lens.settings.filters?.length ? ` · ${lens.settings.filters.length} filters` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
|
|
@ -273,16 +272,13 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
update(`/engine/${engine.id}`, { ...engine.settings, enabled: !engine.settings.enabled }, "put")
|
||||
update(`/lens/${lens.id}`, { ...lens.settings, enabled: !lens.settings.enabled }, "put")
|
||||
}
|
||||
>
|
||||
{engine.settings.enabled ? <Pause className="size-3" /> : <Play className="size-3" />}
|
||||
{engine.settings.enabled ? "Pause" : "Resume"}
|
||||
{lens.settings.enabled ? <Pause className="size-3" /> : <Play className="size-3" />}
|
||||
{lens.settings.enabled ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !!active || !connected}
|
||||
onClick={() => update(`/engine/${engine.id}/runs`, {})}
|
||||
>
|
||||
<Button disabled={busy || !!active || !connected} onClick={() => update(`/lens/${lens.id}/runs`, {})}>
|
||||
<Play className="size-3" />
|
||||
Run now
|
||||
</Button>
|
||||
|
|
@ -298,18 +294,18 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
<div>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<p className="mt-1 text-sm font-medium" role="status">
|
||||
{engineStatus(engine, connected)}
|
||||
{lensStatus(lens, connected)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{engine.settings.enabled
|
||||
? `Checks every ${engine.settings.interval_minutes} minutes`
|
||||
{lens.settings.enabled
|
||||
? `Checks every ${lens.settings.interval_minutes} minutes`
|
||||
: "Manual analysis available"}
|
||||
</p>
|
||||
<NextCheck engine={engine} />
|
||||
<NextCheck lens={lens} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Last successful scan</p>
|
||||
<p className="mt-1 text-sm">{when(lastCompleted?.finished_at ?? engine.last_scan_at)}</p>
|
||||
<p className="mt-1 text-sm">{when(lastCompleted?.finished_at ?? lens.last_scan_at)}</p>
|
||||
{lastCompleted && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{lastCompleted.coverage?.screened ?? 0} of {lastCompleted.coverage?.eligible ?? 0} eligible runs
|
||||
|
|
@ -320,21 +316,21 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
<div>
|
||||
<p className="text-xs text-muted-foreground">Analysis spend this month</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{money(engine.budget_month === new Date().toISOString().slice(0, 7) ? engine.spent ?? 0 : 0)}{" "}
|
||||
<span className="text-muted-foreground">/ {money(engine.settings.monthly_budget ?? 20)}</span>
|
||||
{money(lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0)}{" "}
|
||||
<span className="text-muted-foreground">/ {money(lens.settings.monthly_budget ?? 20)}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Includes reservations for pending calls</p>
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<EngineProgress
|
||||
<LensProgress
|
||||
key={active.id}
|
||||
job={active}
|
||||
onCancel={
|
||||
readOnly
|
||||
? undefined
|
||||
: () => {
|
||||
void update(`/engine/${engine.id}/cancel`, {});
|
||||
void update(`/lens/${lens.id}/cancel`, {});
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
|
@ -344,7 +340,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
{job.error}
|
||||
</p>
|
||||
)}
|
||||
<Tabs value={tab} onValueChange={setTab} key={engine.id}>
|
||||
<Tabs value={tab} onValueChange={setTab} key={lens.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="findings">Findings</TabsTrigger>
|
||||
|
|
@ -369,7 +365,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
{when(job.created_at)} · {job.status}
|
||||
</option>
|
||||
)}
|
||||
{(history.data ?? engine.jobs)?.map((j) => (
|
||||
{(history.data ?? lens.jobs)?.map((j) => (
|
||||
<option key={j.id} value={j.id}>
|
||||
{when(j.created_at)} · {j.status}
|
||||
</option>
|
||||
|
|
@ -482,7 +478,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
{visibleFindings.length === 0 && (
|
||||
<div className="px-6 py-14 text-center">
|
||||
<CheckCircle2 className="mx-auto mb-3 size-5 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">{emptyFindingTitle(!!active, !!engine.last_scan_at)}</p>
|
||||
<p className="text-sm font-medium">{emptyFindingTitle(!!active, !!lens.last_scan_at)}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{active
|
||||
? "Lens is reviewing the selected activity."
|
||||
|
|
@ -517,10 +513,10 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
variant="ghost"
|
||||
onClick={() =>
|
||||
update(
|
||||
`/engine/${engine.id}`,
|
||||
`/lens/${lens.id}`,
|
||||
{
|
||||
...engine.settings,
|
||||
checks: engine.settings.checks.map((q) =>
|
||||
...lens.settings,
|
||||
checks: lens.settings.checks.map((q) =>
|
||||
q.id === c.id ? { ...q, enabled: !q.enabled } : q,
|
||||
),
|
||||
},
|
||||
|
|
@ -537,7 +533,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
<Button
|
||||
variant="outline"
|
||||
disabled={!!active || !connected}
|
||||
onClick={() => update(`/engine/${engine.id}/runs`, {})}
|
||||
onClick={() => update(`/lens/${lens.id}/runs`, {})}
|
||||
>
|
||||
Run saved settings now
|
||||
</Button>
|
||||
|
|
@ -590,7 +586,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
</Button>
|
||||
</div>
|
||||
{history.error && <p role="alert">{history.error.message}</p>}
|
||||
{(history.data ?? engine.jobs)?.map((j) => (
|
||||
{(history.data ?? lens.jobs)?.map((j) => (
|
||||
<div key={j.id} className="rounded-lg border p-4">
|
||||
<div className="flex justify-between gap-3 text-sm">
|
||||
<span className="font-medium">{j.stage}</span>
|
||||
|
|
@ -621,7 +617,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<EngineSetup
|
||||
<LensSetup
|
||||
mode={editing}
|
||||
initial={setupSettings()}
|
||||
models={models.data?.data.map((m) => m.id) ?? []}
|
||||
|
|
@ -750,7 +746,7 @@ export function EngineView({ accessToken, readOnly = false }: { accessToken: str
|
|||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{engine && target?.source === "traces" && (
|
||||
{lens && target?.source === "traces" && (
|
||||
<TracePanel
|
||||
open={!!evidence}
|
||||
traceId={target.id}
|
||||
|
|
@ -42,7 +42,7 @@ describe("Worker setup", () => {
|
|||
await user.click(screen.getByRole("combobox", { name: "Charge analysis to" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Analysis" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate setup command" }));
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/engine/workers/register", {
|
||||
expect(apiClient.post).toHaveBeenCalledWith("/lens/workers/register", {
|
||||
accessToken: "admin",
|
||||
body: { name: "Lens analyzer", analysis_key_id: "b".repeat(64) },
|
||||
});
|
||||
|
|
@ -71,7 +71,7 @@ describe("Worker setup", () => {
|
|||
await user.click(screen.getByRole("combobox", { name: "Charge analysis to" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Analysis" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save billing key" }));
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/engine/workers/worker/billing-key", {
|
||||
expect(apiClient.put).toHaveBeenCalledWith("/lens/workers/worker/billing-key", {
|
||||
accessToken: "admin",
|
||||
body: { analysis_key_id: "b".repeat(64) },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { Input } from "@/components/ui/input";
|
|||
import { serverRootPath } from "@/lib/serverRootPath";
|
||||
import { apiClient, proxyBaseUrl } from "@/components/networking";
|
||||
import { AnalysisKey } from "./AnalysisKey";
|
||||
import type { EngineList, WorkerCreated } from "./engineData";
|
||||
import type { LensList, WorkerCreated } from "./lensData";
|
||||
|
||||
export const LENS_WORKER_IMAGE =
|
||||
"ghcr.io/berriai/litellm-lens-worker@sha256:c41e932eaf3e4efbcaf8cc5027c7e93021e5b2823f21cb8785cd107e37b91c9a";
|
||||
"ghcr.io/berriai/litellm-lens-worker@sha256:a8e8731d954916594eea462969946b9292fb771681ff515a9fd296b53f856c77";
|
||||
|
||||
function initialProxyAddress(): string {
|
||||
const url = new URL(proxyBaseUrl || serverRootPath, window.location.origin);
|
||||
|
|
@ -30,7 +30,7 @@ export function workerSetupCommand(address: string, token: string): string {
|
|||
].join(" \\\n");
|
||||
}
|
||||
|
||||
function workerStatus(worker: EngineList["workers"][number], now: number): string {
|
||||
function workerStatus(worker: LensList["workers"][number], now: number): string {
|
||||
if (!worker.analysis_key_id) return "Billing key required";
|
||||
return now - Date.parse(worker.last_seen) < 120000 ? "Connected · ready to analyze" : "Not connected";
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ export function WorkerSetup({
|
|||
onChanged,
|
||||
}: {
|
||||
accessToken: string;
|
||||
workers: EngineList["workers"];
|
||||
workers: LensList["workers"];
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
|
|
@ -59,7 +59,7 @@ export function WorkerSetup({
|
|||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const actionLabel = editingWorker ? "Save billing key" : "Generate setup command";
|
||||
const editBilling = (worker: EngineList["workers"][number]) => {
|
||||
const editBilling = (worker: LensList["workers"][number]) => {
|
||||
setCreated(null);
|
||||
setEditingWorker(worker.id);
|
||||
setAnalysisKey(worker.analysis_key_id ?? null);
|
||||
|
|
@ -69,7 +69,7 @@ export function WorkerSetup({
|
|||
setError("");
|
||||
try {
|
||||
if (editingWorker) {
|
||||
await apiClient.put(`/engine/workers/${editingWorker}/billing-key`, {
|
||||
await apiClient.put(`/lens/workers/${editingWorker}/billing-key`, {
|
||||
accessToken,
|
||||
body: { analysis_key_id: analysisKey },
|
||||
});
|
||||
|
|
@ -79,7 +79,7 @@ export function WorkerSetup({
|
|||
return;
|
||||
}
|
||||
setCreated(
|
||||
await apiClient.post<WorkerCreated>("/engine/workers/register", {
|
||||
await apiClient.post<WorkerCreated>("/lens/workers/register", {
|
||||
accessToken,
|
||||
body: { name: "Lens analyzer", analysis_key_id: analysisKey },
|
||||
}),
|
||||
|
|
@ -189,7 +189,7 @@ export function WorkerSetup({
|
|||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiClient.delete(`/engine/workers/${worker.id}`, { accessToken });
|
||||
await apiClient.delete(`/lens/workers/${worker.id}`, { accessToken });
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not revoke worker");
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
sortedFindings,
|
||||
type Finding,
|
||||
type Job,
|
||||
} from "./engineData";
|
||||
} from "./lensData";
|
||||
|
||||
const coverage: Job["coverage"] = {
|
||||
eligible: 0,
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type Engine = components["schemas"]["Engine"];
|
||||
export type Settings = components["schemas"]["EngineSettings"];
|
||||
export type EngineList = components["schemas"]["EngineList"];
|
||||
export type Lens = components["schemas"]["Lens"];
|
||||
export type Settings = components["schemas"]["LensSettings"];
|
||||
export type LensList = components["schemas"]["LensList"];
|
||||
export type Finding = components["schemas"]["Finding"];
|
||||
export type Sample = components["schemas"]["Sample"];
|
||||
export type WorkerCreated = components["schemas"]["WorkerCreated"];
|
||||
|
|
@ -33,12 +33,12 @@ export function sortedFindings(findings: Finding[]): Finding[] {
|
|||
);
|
||||
}
|
||||
|
||||
export function engineStatus(engine: Engine, connected: boolean): string {
|
||||
const active = engine.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
|
||||
export function lensStatus(lens: Lens, connected: boolean): string {
|
||||
const active = lens.jobs?.find((job) => ["queued", "running"].includes(job.status ?? ""));
|
||||
if (active) return connected ? active.stage ?? "Queued" : "Waiting for analyzer";
|
||||
const spent = engine.budget_month === new Date().toISOString().slice(0, 7) ? engine.spent ?? 0 : 0;
|
||||
if (spent >= (engine.settings.monthly_budget ?? 20)) return "Budget reached";
|
||||
if (!engine.settings.enabled) return "Paused";
|
||||
const spent = lens.budget_month === new Date().toISOString().slice(0, 7) ? lens.spent ?? 0 : 0;
|
||||
if (spent >= (lens.settings.monthly_budget ?? 20)) return "Budget reached";
|
||||
if (!lens.settings.enabled) return "Paused";
|
||||
return connected ? "Monitoring" : "Analyzer disconnected";
|
||||
}
|
||||
|
||||
|
|
@ -152,12 +152,12 @@ const nextCheckTimeFormat: Intl.DateTimeFormatOptions = {
|
|||
minute: "2-digit",
|
||||
};
|
||||
|
||||
export function nextCheckStatus(engine: Engine, now: number): string | null {
|
||||
if (!engine.settings.enabled) return null;
|
||||
const active = engine.jobs.find((job) => job.status === "queued" || job.status === "running");
|
||||
export function nextCheckStatus(lens: Lens, now: number): string | null {
|
||||
if (!lens.settings.enabled) return null;
|
||||
const active = lens.jobs.find((job) => job.status === "queued" || job.status === "running");
|
||||
if (active?.status === "running") return "Next check scheduled after this scan finishes";
|
||||
if (active?.status === "queued") return "Waiting for an analyzer";
|
||||
const next = new Date(engine.next_run_at);
|
||||
const next = new Date(lens.next_run_at);
|
||||
const remaining = next.getTime() - now;
|
||||
if (remaining <= 0) return "Due now · waiting for an analyzer";
|
||||
const minutes = Math.ceil(remaining / 60000);
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
|
||||
import { EngineView } from "./_components/EngineView";
|
||||
import { LensView } from "./_components/LensView";
|
||||
|
||||
export default function EnginePage() {
|
||||
export default function LensPage() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
if (!accessToken) return null;
|
||||
if (!isProxyAdminTierRole(userRole ?? "")) {
|
||||
return <p className="p-6 text-sm text-muted-foreground">Lens requires proxy administrator access.</p>;
|
||||
}
|
||||
return <EngineView accessToken={accessToken} readOnly={!isProxyAdminRole(userRole ?? "")} />;
|
||||
return <LensView accessToken={accessToken} readOnly={!isProxyAdminRole(userRole ?? "")} />;
|
||||
}
|
||||
|
|
|
|||
2272
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
uv.lock
generated
|
|
@ -4969,6 +4969,16 @@ source = { editable = "enterprise" }
|
|||
name = "litellm-proxy-extras"
|
||||
version = "0.4.103"
|
||||
source = { editable = "litellm-proxy-extras" }
|
||||
dependencies = [
|
||||
{ name = "psycopg" },
|
||||
{ name = "psycopg-binary" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "psycopg", specifier = ">=3.2,<4.0" },
|
||||
{ name = "psycopg-binary", specifier = ">=3.2,<4.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llm-sandbox"
|
||||
|
|
|
|||