A FastAPI WebSocket service that runs LangSmith evaluations on demand, plus a
CLI entrypoint (run_evals.py) for one-shot CI/CD runs (e.g. AWS ECS Fargate
tasks triggered after a backend deploy).
Phase 1 ships LLM-as-judge evaluators (Claude with forced tool-use for structured scores, via the Anthropic API by default or Amazon Bedrock) plus a set of heuristic / code evaluators, and supports targets that are either an existing chatbot HTTP API or a fallback Claude LLM call.
| Mode | Use it when | Entrypoint |
|---|---|---|
| WebSocket server | Interactive UI / dashboards / long-lived service | python -m app.server |
| CLI (one-shot) | CI/CD gates, ECS RunTask, scheduled cron | python run_evals.py |
The CLI does not require the WebSocket server — it imports the runner in-process and exits 0/1 based on threshold gates.
A client opens a WebSocket connection to /ws/evaluate and sends one JSON
payload describing the run. The server then:
- Validates the payload.
- Resolves the requested evaluator names against the registry.
- Syncs the LangSmith dataset:
- if
examplesare omitted, it just loads the existing dataset; - if
examplesare provided and the dataset doesn't exist, it's created; - if the dataset exists, incoming examples are compared via a deterministic
fingerprint of
(inputs, outputs). If they match the dataset is left alone; otherwise the existing examples are deleted and replaced.
- if
- For each example, calls the configured target to produce an output
(concurrency capped by
max_concurrency). - Runs every evaluator on the example.
- Streams a message per example, then a final summary message.
This project uses uv (Python 3.14, pinned in
.python-version):
uv sync # creates .venv and installs from uv.lock
cp .env.example .env # then fill in keysIf you'd rather not install uv, a plain venv works too:
python -m venv .venv
. .venv/Scripts/activate # Windows: .venv\Scripts\activate
# macOS/Linux: source .venv/bin/activate
pip install -e . # installs the project + its dependenciesRequired env vars: LANGSMITH_API_KEY, plus ANTHROPIC_API_KEY for the
default LLM-as-judge backend.
By default the judge calls Claude directly via the Anthropic API, so
ANTHROPIC_API_KEY is all you need to run the examples below.
ANTHROPIC_API_KEY is also used when target.type = "llm" (the
Claude-as-system-under-test fallback).
To run the judge on Amazon Bedrock instead, set JUDGE_PROVIDER=bedrock
(or LLM_PROVIDER=bedrock to switch both judge and target). Bedrock needs
AWS credentials with bedrock:InvokeModel in the judge's region (defaults
to us-east-1) — supplied however boto3 normally finds them
(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, an AWS_PROFILE, or an
attached IAM role) — plus model access enabled for the cross-region
inference profile in JUDGE_MODEL (Bedrock default
us.anthropic.claude-sonnet-4-6; Anthropic default claude-sonnet-4-6).
See Provider selection for the full resolution order.
For LangSmith Hybrid set LANGSMITH_ENDPOINT to your hybrid URL.
Both the LLM judge and the LLM target (target.type = "llm") can run on
either the Anthropic API (anthropic) or Amazon Bedrock (bedrock).
Each knob resolves independently:
payload field -> specific env -> LLM_PROVIDER env -> default
| Knob | Payload field | Specific env | Default |
|---|---|---|---|
| Judge provider | judge.provider |
JUDGE_PROVIDER |
anthropic |
| Judge model | judge.model |
JUDGE_MODEL |
per-provider |
| Target provider | target.provider |
TARGET_PROVIDER |
anthropic |
| Target model | target.model |
TARGET_MODEL |
per-provider |
LLM_PROVIDER sets the default for both at once. Per-provider model defaults:
claude-sonnet-4-6 for anthropic, us.anthropic.claude-sonnet-4-6 for
bedrock. The caller must match the model-id format to the provider — there is
no auto-translation. The resolved values are echoed back in the started
event so a client can audit what the run actually used.
python -m app.server
# or
uvicorn app.server:app --host 0.0.0.0 --port 8000Rules:
dataset_nameis always required.- If
examplesis supplied, see the sync rules above. target.type = "none"means each example already includes itsoutputs; evaluators run against those without hitting any chatbot/LLM.
LLM-as-judge (Claude via the Anthropic API or Amazon Bedrock; forced
submit_evaluation tool call so every response is a typed
{score, reasoning} object — no regex JSON extraction):
correctness, semantic_similarity, pairwise, faithfulness,
answer_relevance, toxicity, conciseness, coherence.
Heuristic / code:
exact_match, json_validity, regex_match, key_phrases, trajectory.
GET /evaluators returns the full list.
| Evaluator | Reads from example |
|---|---|
correctness |
outputs (reference) compared to model output |
semantic_similarity |
same |
pairwise |
outputs is treated as the baseline (A); model output is B. Set metadata.pairwise_criterion to override the criterion. |
faithfulness |
inputs.context or inputs.retrieved_context or metadata.context |
answer_relevance |
inputs (the question) |
toxicity |
model output only |
conciseness |
question + model output |
coherence |
model output only |
exact_match |
deep-equality of model output and outputs reference (skips if no reference) |
json_validity |
model output |
regex_match |
metadata.regex against model output |
key_phrases |
metadata.key_phrases (or outputs.key_phrases) inside model output |
trajectory |
outputs.tool_calls (expected) vs model outputs.tool_calls/trajectory |
Every server -> client message is JSON { "type": "...", "data": { ... } }:
type |
Sent when |
|---|---|
started |
The run was accepted; echoes evaluators + metadata |
dataset_synced |
After create/update/loaded; includes action |
run_planned |
After examples are pulled; gives total + evaluator names |
example_completed |
One example finished. Includes per-evaluator scores and progress |
example_failed |
Target invocation raised; that example is skipped |
completed |
Final summary with per-evaluator mean |
error |
Fatal error — connection then closes |
Two paths, depending on whether you want to drive evals through the long-running WebSocket service or kick off a one-shot ECS task. Both assume the Terraform stack from Deploying to AWS has been applied and your laptop is on the corporate network / VPN (the ALB is internal-facing).
Grab the ALB hostname from Terraform:
ALB=$(terraform -chdir=iac output -raw alb_dns_name)
# WSS_URL is ws://... by default (HTTP listener),
# or wss://... when acm_certificate_arn is set on the Terraform stack.
WSS_URL="ws://$ALB/ws/evaluate"Interactive (wscat):
# install once: npm i -g wscat
wscat -c "$WSS_URL"
# now paste a single-line JSON payload at the > prompt, hit Enter,
# and watch the streamed `started` -> `dataset_synced` ->
# `example_completed` -> ... -> `completed` events come back.A minimal payload (one example, baseline against the LLM target):
{"dataset_name":"demo-qa","examples":[{"inputs":{"input":"What color is the sky on a clear day?"},"outputs":{"output":"Blue."}}],"evaluators":["correctness","answer_relevance"],"target":{"type":"llm"},"allow_overwrite":true}Programmatic (Python, websockets):
# pip install websockets
import asyncio, json, websockets
PAYLOAD = {
"dataset_name": "demo-qa",
"examples": [{"inputs": {"input": "What color is the sky on a clear day?"},
"outputs": {"output": "Blue."}}],
"evaluators": ["correctness", "answer_relevance"],
"target": {"type": "llm"},
"allow_overwrite": True,
}
async def run():
async with websockets.connect("ws://<alb-dns>/ws/evaluate") as ws:
await ws.send(json.dumps(PAYLOAD))
async for raw in ws:
evt = json.loads(raw)
print(evt["type"], evt["data"])
if evt["type"] in ("completed", "error"):
break
asyncio.run(run())The connection closes after a terminal completed or error event — one
payload per connection by design.
Useful when you want to run the CI evaluator suite right now without waiting for a backend deploy. Same image, same task definition the GitHub Actions workflow uses — just kicked off by hand. Pulls all the IDs from Terraform output so you don't have to look them up:
CLUSTER=$(terraform -chdir=iac output -raw ecs_cluster_name)
TASKDEF=$(terraform -chdir=iac output -raw cli_task_definition_family)
TASK_SG=$(terraform -chdir=iac output -raw task_security_group_id)
SUBNETS=$(terraform -chdir=iac output -json private_subnet_ids | jq -r 'join(",")')
aws ecs run-task \
--cluster "$CLUSTER" \
--task-definition "$TASKDEF" \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$TASK_SG],assignPublicIp=DISABLED}" \
--overrides '{
"containerOverrides":[{
"name":"evals",
"environment":[
{"name":"DATASET_NAME","value":"demo-qa"},
{"name":"EVALUATORS","value":"correctness,answer_relevance"},
{"name":"TARGET_URL","value":"http://your-chatbot.internal/api/chat"},
{"name":"THRESHOLDS","value":"correctness>=0.5"},
{"name":"ALLOW_OVERWRITE","value":"true"}
]
}]
}'Wait for it to finish and pull logs:
TASK_ARN=... # from the run-task output above
aws ecs wait tasks-stopped --cluster "$CLUSTER" --tasks "$TASK_ARN"
aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
--query 'tasks[0].containers[0].exitCode' --output text
# Streamed JSON events from run_evals.py end up here:
aws logs tail /ecs/langsmith-evals-dev-cli --followExit code 0 = all gates passed, 1 = threshold breach or example error,
2 = configuration error. Your IAM principal needs ecs:RunTask,
ecs:DescribeTasks, logs:GetLogEvents, and iam:PassRole on the task
execution + task roles for this to work from your machine.
run_evals.py is built for a "deploy backend → spin up an ECS task to run
evals → tear it down" flow. It reads all configuration from environment
variables (or matching CLI flags), prints one JSON event per line to stdout,
and exits non-zero on threshold breach or example failure.
| Variable | Notes |
|---|---|
DATASET_NAME |
LangSmith dataset name |
EVALUATORS |
CSV: correctness,faithfulness,toxicity |
TARGET_URL |
Chatbot HTTP endpoint (omit to use Claude LLM fallback) |
THRESHOLDS |
CSV gates, e.g. correctness>=0.9,toxicity==1.0 |
METADATA_JSON |
JSON dict, e.g. {"git_sha":"abc1234"} |
LANGSMITH_API_KEY, optionally LANGSMITH_ENDPOINT. ANTHROPIC_API_KEY
is only required when target.type = "llm". Bedrock access for the
judge comes from the task IAM role — give it
bedrock:InvokeModel on the model ARN you target (and enable model
access in the AWS account); no API key needed.
TARGET_INPUT_KEY, TARGET_RESPONSE_PATH, TARGET_HEADERS_JSON,
TARGET_MODEL, TARGET_SYSTEM_PROMPT, TARGET_TIMEOUT_S,
EXAMPLES_FILE (path to JSON array, baked into image or mounted),
EXPERIMENT_PREFIX, MAX_CONCURRENCY,
JUDGE_MODEL (Bedrock model ID, e.g. us.anthropic.claude-sonnet-4-6),
ALLOW_OVERWRITE (set true to replace examples in an existing
dataset; defaults to false so a run with the wrong DATASET_NAME
fails cleanly instead of wiping the dataset),
FAIL_ON_EXAMPLE_ERROR (default 1).
correctness>=0.9, semantic_similarity>=0.85, toxicity==1.0, faithfulness>=0.8
Operators: >= <= > < == !=. Each gate is checked against the
mean score of that evaluator across the run. Missing evaluator (no scores
produced) counts as a breach.
| Code | Meaning |
|---|---|
0 |
All gates passed and no example errors |
1 |
Threshold breach or example error |
2 |
Configuration error (bad payload, missing required field, etc.) |
docker build -t langsmith-evals .
docker run --rm \
-e LANGSMITH_API_KEY \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_REGION=us-east-1 \
-e DATASET_NAME=chatbot-regression \
-e EVALUATORS="correctness,faithfulness,toxicity" \
-e TARGET_URL=https://chatbot.example.com/api/chat \
-e THRESHOLDS="correctness>=0.9,toxicity==1.0" \
langsmith-evals(On ECS Fargate the AWS creds come from the task role; locally you supply
them via env vars, ~/.aws/credentials, or AWS_PROFILE. Add
-e ANTHROPIC_API_KEY only if you set TARGET_URL to empty / use the
LLM target.)
Pre-create a task definition that references your image in ECR and pulls secrets from Secrets Manager. Per run, override only the per-run env vars:
aws ecs run-task \
--cluster evals-cluster \
--task-definition langsmith-evals \
--launch-type FARGATE \
--network-configuration 'awsvpcConfiguration={subnets=[...],securityGroups=[...],assignPublicIp=ENABLED}' \
--overrides '{
"containerOverrides":[{
"name":"evals",
"environment":[
{"name":"DATASET_NAME","value":"chatbot-regression"},
{"name":"EVALUATORS","value":"correctness,faithfulness,toxicity"},
{"name":"TARGET_URL","value":"https://chatbot.example.com/api/chat"},
{"name":"THRESHOLDS","value":"correctness>=0.9,toxicity==1.0"},
{"name":"METADATA_JSON","value":"{\"git_sha\":\"abc1234\"}"}
]
}]
}'Then aws ecs wait tasks-stopped and read
tasks[0].containers[0].exitCode — that is the gate result.
A working example is in .github/workflows/evals.yml.
It triggers on a successful run of your deploy-backend workflow, builds and
pushes the image to ECR, registers a fresh task-definition revision pointing
at the new image, runs the ECS task with overrides, waits for it to stop, and
propagates the container exit code. Configure these once:
- Repository secret:
AWS_OIDC_ROLE_ARN(an IAM role trusted by GitHub OIDC) - Repository variable:
CHATBOT_URL(default backend URL to evaluate) - Update the workflow's
env:block (ECS_CLUSTER,ECS_TASK_DEFINITION,ECS_SUBNETS,ECS_SECURITY_GROUPS) with the values produced by the Terraform stack — see Deploying to AWS.
The OIDC role's permission policy needs (at minimum):
{
"Version": "2012-10-17",
"Statement": [
{
// Push the freshly-built image
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage"
],
"Resource": "*"
},
{
// Register a new task-def revision pointing at the new image, then run it
"Effect": "Allow",
"Action": [
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition",
"ecs:RunTask",
"ecs:DescribeTasks"
],
"Resource": "*"
},
{
// PassRole on the task's execution + task roles so RunTask can assume them
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::<account>:role/<ecsTaskExecutionRole>",
"arn:aws:iam::<account>:role/<ecsTaskRole>"
]
}
]
}ecs:RegisterTaskDefinition is required because ECS does not support
overriding containerDefinitions[].image via run-task containerOverrides —
the workflow registers a new revision per run so the freshly-built image is
actually executed. Without this permission the "Register new task definition
revision" step fails with AccessDenied.
The iac/ directory contains a Terraform stack that provisions everything
the CI workflow and the WebSocket service need to run on AWS Fargate:
- ECS cluster (Fargate + Fargate Spot capacity providers, Container Insights on)
- Two task-definition families:
-servicefor the long-running WebSocket server,-clifor one-shot CI runs - ECS service wired to an internal ALB with a
/healthtarget-group check, 3600s idle timeout, and 1800s deregistration delay (so long-running WebSocket evals don't get killed during deploys) - Task execution + task IAM roles (Bedrock
InvokeModel, Secrets Manager read forLANGSMITH_API_KEY/ optionalANTHROPIC_API_KEY) - CloudWatch log groups, ALB + ECS security groups, HTTP listener (HTTPS
auto-switched on when
acm_certificate_arnis set)
The Terraform stack does not create networking primitives — bring your own VPC and subnets:
| Variable | Required | Notes |
|---|---|---|
vpc_id |
yes | VPC for the ALB and tasks |
alb_subnet_ids |
yes | ≥2 subnets across distinct AZs for the internal ALB |
private_subnet_ids |
yes | Subnets for ECS tasks; need egress to Bedrock / LangSmith / ECR (NAT or VPC endpoints) |
alb_allowed_cidrs |
yes | CIDR blocks permitted to reach the ALB (your VPC range, VPN range, etc.) |
acm_certificate_arn |
no | Provide to switch the ALB listener from HTTP to HTTPS |
judge_model |
no | Defaults to us.anthropic.claude-sonnet-4-6 |
The API-key secret (LangSmith + Anthropic, as a JSON object) is created
by the Terraform stack — no *_secret_arn inputs to pass. After
terraform apply, populate the real values once:
aws secretsmanager put-secret-value \
--secret-id "$(terraform output -raw api_keys_secret_name)" \
--secret-string '{"LANGSMITH_API_KEY":"ls_...","ANTHROPIC_API_KEY":"sk-ant-..."}'Terraform's lifecycle.ignore_changes on the initial version keeps it
from reverting the value on the next apply.
cd iac
terraform init
terraform plan \
-var vpc_id=vpc-xxxxxxxx \
-var 'alb_subnet_ids=["subnet-aaaa","subnet-bbbb"]' \
-var 'private_subnet_ids=["subnet-cccc","subnet-dddd"]' \
-var 'alb_allowed_cidrs=["10.0.0.0/8"]'
terraform apply(Consider configuring the S3 + DynamoDB backend stub in providers.tf
before applying in a shared environment.)
After apply, terraform output will give you the names you need:
terraform output ecs_cluster_name # -> langsmith-evals-dev-cluster
terraform output cli_task_definition_family # -> langsmith-evals-dev-cli
terraform output task_security_group_id # -> sg-xxxxxxxx
terraform output private_subnet_ids # -> ["subnet-cccc","subnet-dddd"]Update .github/workflows/evals.yml's env: block accordingly:
ECS_CLUSTER: langsmith-evals-dev-cluster
ECS_TASK_DEFINITION: langsmith-evals-dev-cli # the -cli family, NOT -service
ECS_SUBNETS: subnet-cccc,subnet-dddd
ECS_SECURITY_GROUPS: sg-xxxxxxxx
CONTAINER_NAME: evals# CLI mode (no server needed):
DATASET_NAME=demo-qa \
EVALUATORS=correctness,answer_relevance \
THRESHOLDS=correctness>=0.5 \
EXAMPLES_FILE=examples/demo_qa.json \
python run_evals.pyapp/
server.py FastAPI + WebSocket endpoint
schemas.py Pydantic models
dataset_sync.py Create / update / compare LangSmith datasets
target.py Chatbot HTTP target + Claude fallback
runner.py Orchestrates the run; emits streamed events
thresholds.py CI gating spec parser + checker
evaluators/
registry.py name -> async callable
llm_judge.py Claude-on-Bedrock evaluators (forced tool-use)
heuristic.py Code / exact-match / regex / JSON / trajectory evaluators
run_evals.py CLI entrypoint (CI/CD; one-shot ECS task)
Dockerfile Slim image; default CMD = uvicorn (CLI command override in -cli task def)
iac/ Terraform: ECS cluster, service, task defs, ALB, IAM, SGs, logs
variables.tf Required + optional inputs
outputs.tf ALB DNS, cluster name, task-def families, SG / subnet IDs
...
.github/workflows/
evals.yml GitHub Actions: build image, register task-def revision, run ECS task
- Pluggable judge backend (Bedrock is the current default; OpenAI / direct Anthropic API as alternates).
- Persisting evaluator scores back onto the LangSmith experiment.
- Auth (API key / JWT) on the WebSocket handshake.
- Multi-run batching over a single connection.
{ "dataset_name": "my-dataset", // required "examples": [ // optional { "inputs": { "input": "..." }, "outputs": { "output": "..." }, // ground truth (optional) "metadata": { "topic": "..." } // per-example metadata (optional) } ], "evaluators": "correctness,semantic_similarity", // CSV string OR ["..."] "metadata": { "client_app": "...", "release": "v1.2.3" }, "target": { "type": "http" | "llm" | "none", "url": "https://your-chatbot/api/chat", // type=http "headers": { "Authorization": "Bearer ..." }, // type=http "input_key": "input", "response_path": "data.message", // dot-path into JSON "timeout_s": 60.0, "model": "claude-sonnet-4-6", // type=llm "system_prompt": "Answer concisely." // type=llm }, "experiment_prefix": "my-run", "max_concurrency": 4, "allow_overwrite": false // must be true to replace examples in an existing dataset }