Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
c756ad6
test(web): add setup-card classes to stylesheet contract
duncankmckinnon Aug 8, 2026
833c3b2
test(web): add setup client tests ahead of implementation
duncankmckinnon Aug 8, 2026
14ff629
test(config): add tests for logfire config keys and presence helpers
duncankmckinnon Aug 8, 2026
1c30075
feat(web): add setup card CSS classes
duncankmckinnon Aug 8, 2026
cce89f6
feat(web): add read-only setup client
duncankmckinnon Aug 8, 2026
29170e6
test: add tests for ExperimentRun table and cancel guard
duncankmckinnon Aug 8, 2026
c5031ca
test(spec): pin OutputT schema for pushed dataset generics
duncankmckinnon Aug 8, 2026
68dd01c
feat(config): add logfire token/api key fields and presence helpers
duncankmckinnon Aug 8, 2026
d5385ec
fix(spec): derive OutputT from label schema for pushed dataset generics
duncankmckinnon Aug 8, 2026
8c5d79b
feat(models): add ExperimentRun table for the pydantic-evals engine
duncankmckinnon Aug 8, 2026
1054ab3
test: cover set_experiment replace-not-accumulate semantics
duncankmckinnon Aug 8, 2026
a1985fa
Merge wb/experiment-model into logfire-integration
duncankmckinnon Aug 8, 2026
966e949
Merge wb/spec-generics into logfire-integration
duncankmckinnon Aug 8, 2026
78e4f4c
Merge wb/web-setup-client into logfire-integration
duncankmckinnon Aug 8, 2026
44e684f
Merge wb/web-setup-styles into logfire-integration
duncankmckinnon Aug 8, 2026
d3f35b0
test: cover useSetup hook (gateway readiness gate)
duncankmckinnon Aug 8, 2026
2053384
feat: add useSetup hook for gateway-key gating
duncankmckinnon Aug 8, 2026
319deb9
test: cover valcore.tracing configure/run_span/row_span behavior
duncankmckinnon Aug 8, 2026
af16b3c
feat: implement valcore.tracing for Logfire run/row span shaping
duncankmckinnon Aug 8, 2026
f0d8228
test: lock in silent opt-out path and no direct logfire import in tra…
duncankmckinnon Aug 8, 2026
4bf9646
fix: derive gatewayReady from loading/error, not stale status
duncankmckinnon Aug 8, 2026
a02e6ce
test: cover logfire dataset push (src/valcore/logfire_io.py)
duncankmckinnon Aug 8, 2026
c1bc9fc
feat: push valcore datasets to Logfire's hosted dataset store
duncankmckinnon Aug 8, 2026
f5bb35b
fix: keep tracing spans and config idempotency guard truly no-op
duncankmckinnon Aug 8, 2026
7d29752
fix: close AsyncLogfireAPIClient and widen exception boundary in push…
duncankmckinnon Aug 8, 2026
9af991a
Merge wb/logfire-io into logfire-integration
duncankmckinnon Aug 8, 2026
3c7db12
Merge wb/web-setup-hook into logfire-integration
duncankmckinnon Aug 8, 2026
1b528be
test: add failing coverage for gateway-key gating on dataset forms
duncankmckinnon Aug 8, 2026
0b9c4eb
test: add failing tests for runner span wrapping
duncankmckinnon Aug 8, 2026
609988c
test: add failing tests for the Overview page setup card
duncankmckinnon Aug 8, 2026
2940841
test: gate evaluator generate/refine/run actions behind the gateway key
duncankmckinnon Aug 8, 2026
ebfbf4a
test: add failing tests for the experiment engine
duncankmckinnon Aug 8, 2026
fd2523b
test: add failing tests for the setup endpoint and gateway guard
duncankmckinnon Aug 8, 2026
f96d526
feat: gate dataset-generation forms on the gateway key
duncankmckinnon Aug 9, 2026
7734a61
feat: add setup walkthrough card to Overview page
duncankmckinnon Aug 9, 2026
cb19700
feat: wrap runner execution in valcore.run/score_row tracing spans
duncankmckinnon Aug 9, 2026
3dae133
feat: gate evaluator generate/refine/run actions on the gateway key
duncankmckinnon Aug 9, 2026
6fbc84c
test: add exception-path and EVAL-kind span coverage to runner tracin…
duncankmckinnon Aug 9, 2026
d0b9d0b
feat: add setup endpoint, dataset push, and gateway guard on generati…
duncankmckinnon Aug 9, 2026
7c6a1f6
feat: add the experiment engine built on pydantic_evals.Dataset.evaluate
duncankmckinnon Aug 9, 2026
e1cb196
test: add on_conflict default and guard-ordering regression tests
duncankmckinnon Aug 9, 2026
ef8d380
test: cover PersistResults.teardown(None) directly and fix import order
duncankmckinnon Aug 9, 2026
c0f2b80
fix: guard create_run on the gateway key before persisting the run
duncankmckinnon Aug 9, 2026
5f1a63c
fix: enforce missing-label validation and fail unexpected experiment …
duncankmckinnon Aug 9, 2026
71570f7
Merge wb/experiment-engine into logfire-integration
duncankmckinnon Aug 9, 2026
ef91371
Merge wb/overview-card into logfire-integration
duncankmckinnon Aug 9, 2026
5e623e7
Merge wb/gate-dataset-forms into logfire-integration
duncankmckinnon Aug 9, 2026
e344383
Merge wb/gate-evaluator-forms into logfire-integration
duncankmckinnon Aug 9, 2026
10156ee
Merge wb/api-surfaces into logfire-integration
duncankmckinnon Aug 9, 2026
871d536
test: add failing tests for the experiment command, logfire push, and…
duncankmckinnon Aug 9, 2026
94fa083
feat: add experiment command, logfire push, config setters, and gatew…
duncankmckinnon Aug 9, 2026
b67451b
test: present a default gateway key in test_cli.py
duncankmckinnon Aug 9, 2026
9dd95b0
fix: use effective-presence helpers in config get, use TestModel for …
duncankmckinnon Aug 9, 2026
15114fb
docs: document the setup walkthrough and Logfire integration
duncankmckinnon Aug 9, 2026
7605cc8
fix: close the experiment cancel race, span coverage, and retry-faile…
duncankmckinnon Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,19 +139,39 @@ Override the default model, highest precedence first: an explicit argument,
> OpenAI-compatible endpoint, local models — is a change to that resolution layer and the
> config schema rather than a change to how evaluators, datasets, or runs work.

## Setup

`valcore serve` shows a setup card on the Overview page listing each key valcore knows
about, whether it is currently set, and what it unlocks. Keys are never entered through
the web UI — no secret crosses HTTP — and are always set from the CLI:

```bash
valcore config set-key # required: runs and generation
valcore config set-logfire-token # optional: sends traces to Logfire
valcore config set-logfire-key # optional: pushes datasets to Logfire
```

Without the gateway key, generation and runs are unavailable, and the UI shows why. Manual
authoring, dataset upload, editing, hand-labeling, and every export still work with no key
configured at all.

## Commands

| Command | What it does |
| --- | --- |
| `valcore serve` | Serve the web UI and API (`--port`, `--host`, `--no-browser`). |
| `valcore list <evaluators\|datasets\|runs>` | List resources as a table or, with `--json`, as JSON. |
| `valcore run <evaluator> <dataset>` | Run an evaluator version over a dataset. |
| `valcore experiment <evaluator> <dataset>` | Run an evaluator version over a dataset via `pydantic_evals.Dataset.evaluate`. |
| `valcore export <evaluator>` | Export an evaluator (and, with `--dataset`, a dataset) as a Python script or, with `--format json`, a portable eval package. |
| `valcore import <file>` | Import a JSON eval package back into the local database. |
| `valcore config set-key [KEY]` | Store the gateway API key in the config file. |
| `valcore config set-logfire-token [TOKEN]` | Store the Logfire write token in the config file. |
| `valcore config set-logfire-key [KEY]` | Store the Logfire API key in the config file. |
| `valcore config get` | Show the current config (the key is masked unless `--show-key`). |
| `valcore config path` | Print the path to the config file. |
| `valcore config edit` | Open the config file in `$EDITOR`. |
| `valcore logfire push <dataset>` | Push a dataset to Logfire's hosted dataset store. |
| `valcore skills install` | Install the bundled agent skills (`--claude`, `--copilot`, …). |
| `valcore skills list` | Show the bundled skills and where each is installed. |
| `valcore skills uninstall` | Remove the bundled skills from the selected directories. |
Expand Down Expand Up @@ -281,6 +301,32 @@ Exit codes:
`--min-accuracy` requires a categorical accuracy metric; numeric or unlabeled runs have
no accuracy and error rather than silently passing.

## Logfire

`logfire` is an optional extra — install it to get traces:

```bash
uv tool install 'valcore[logfire]'
```

With a Logfire token configured (see [Setup](#setup)), each `valcore run` opens a
`valcore.run` span carrying the run's evaluator version, dataset, and concurrency, with a
`valcore.score_row` child span per row; on close, the run span records its status and each
agreement metric as attributes, so a Logfire query can filter runs by accuracy directly.
The [Pydantic AI Gateway](https://ai.pydantic.dev/gateway/) already reports the LLM calls
themselves — valcore adds only the surrounding run and row context around them, and
deliberately does not re-report the calls, which would double-count tokens and cost.

`valcore experiment <evaluator> <dataset>` runs the same evaluation through
`pydantic_evals.Dataset.evaluate` instead of `run`'s own engine, so it also appears in
Logfire's experiments view. It persists a run the same way `run` does, so it shows up on
the Runs page too. Unlike `run`, it cannot be cancelled, because `Dataset.evaluate` has no
cancellation.

`valcore logfire push <dataset>` publishes a dataset to Logfire's hosted dataset store.
It needs a Logfire API key (see [Setup](#setup)) with the `project:read_datasets` and
`project:write_datasets` scopes.

## `~/.valcore`

All state lives under `~/.valcore` (mode `0700`). Set `VALCORE_HOME` to relocate it.
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ dependencies = [
[project.scripts]
valcore = "valcore.cli:main"

[project.optional-dependencies]
# The `fastapi` sub-extra brings in opentelemetry-instrumentation-fastapi, required by
# `logfire.instrument_fastapi` in valcore.api.main.create_app.
logfire = ["logfire[fastapi]>=4.39,<5"]

[dependency-groups]
dev = [
"pytest",
Expand All @@ -43,6 +48,10 @@ dev = [
# unpinned bump would change lint results in CI and locally at once.
"ruff>=0.16,<0.17",
"pre-commit>=4,<5",
# Not a runtime dependency — the `logfire` extra. Declared here so the span tests in
# tests/test_tracing.py and tests/test_runner.py run rather than skipping themselves, and
# so create_app's logfire.instrument_fastapi call has opentelemetry-instrumentation-fastapi.
"logfire[fastapi]>=4.39,<5",
]

[build-system]
Expand Down
8 changes: 7 additions & 1 deletion src/valcore/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
from importlib.resources import files as _package_files
from pathlib import Path

import logfire_api as logfire
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles

from valcore.api.dtos import ErrorBody, ErrorResponse
from valcore.config import load_config
from valcore.errors import (
ConfigError,
ContractError,
Expand All @@ -22,6 +24,7 @@
from valcore.models import VALID_CAPABILITIES
from valcore.settings import MODEL_CATALOG
from valcore.tools import tool_names
from valcore.tracing import configure_tracing

_STATUS_BY_ERROR: tuple[tuple[type[ValcoreError], int], ...] = (
(NotFoundError, 404),
Expand Down Expand Up @@ -78,7 +81,7 @@ async def handler(_request: Request, exc: ValcoreError) -> JSONResponse:

def _include_routers(app: FastAPI) -> None:
"""Discover and mount resource routers, tolerating ones that do not exist yet."""
for module_name in ("evaluators", "datasets", "runs", "overview"):
for module_name in ("evaluators", "datasets", "runs", "overview", "setup"):
try:
module = importlib.import_module(f"valcore.api.routes.{module_name}")
except ImportError:
Expand All @@ -100,6 +103,9 @@ def create_app() -> FastAPI:

_register_exception_handlers(app)

configure_tracing(load_config())
logfire.instrument_fastapi(app)

@app.get("/api/health")
async def health() -> dict[str, str]:
"""Liveness probe."""
Expand Down
29 changes: 28 additions & 1 deletion src/valcore/api/routes/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
import json
import re
from datetime import datetime
from typing import Annotated
from typing import Annotated, Literal

from fastapi import APIRouter, Depends, File, Form, UploadFile
from pydantic import BaseModel, ConfigDict

from valcore import config
from valcore.api.deps import get_store
from valcore.config_io import EvalPackage
from valcore.datagen import generate_rows
from valcore.errors import ContractError
from valcore.export import render_dataset_module, render_judge_module
from valcore.logfire_io import push_dataset
from valcore.models import LabelSchema, LabelSource
from valcore.schema_migration import label_matches_schema
from valcore.seeding import dataset_shape_from_version
Expand Down Expand Up @@ -99,6 +101,14 @@ class RowsAppend(BaseModel):
rows: list[dict]


class LogfirePushRequest(BaseModel):
"""Request body to push a dataset to Logfire's hosted dataset store."""

name: str | None = None
description: str | None = None
on_conflict: Literal["update", "error"] = "update"


class RowPatch(BaseModel):
"""Request body to relabel or annotate a single row."""

Expand Down Expand Up @@ -420,6 +430,7 @@ def _check_column_notes(column_notes: dict[str, str] | None, columns: list[str])
@router.post("/generate")
async def generate_dataset(body: DatasetGenerate, store: StoreDep) -> DatasetCreatedOut:
"""Generate a dataset and its rows with suggested labels."""
config.require_gateway_key()
if body.count > _MAX_GENERATE_COUNT:
raise ContractError(f"count may not exceed {_MAX_GENERATE_COUNT}.")

Expand Down Expand Up @@ -467,6 +478,7 @@ async def generate_dataset_from_version(
body: DatasetGenerateFromVersion, store: StoreDep
) -> DatasetCreatedOut:
"""Generate a dataset shaped by an evaluator version, runnable against it by construction."""
config.require_gateway_key()
if body.count > _MAX_GENERATE_COUNT:
raise ContractError(f"count may not exceed {_MAX_GENERATE_COUNT}.")

Expand Down Expand Up @@ -545,6 +557,7 @@ async def generate_more_rows(id: str, body: RowsGenerate, store: StoreDep) -> li
the new rows stay compatible with the existing ones and with any evaluator that already
runs against them.
"""
config.require_gateway_key()
if body.count > _MAX_GENERATE_COUNT:
raise ContractError(f"count may not exceed {_MAX_GENERATE_COUNT}.")

Expand Down Expand Up @@ -731,3 +744,17 @@ async def export_dataset_json(
agent_filename = f"{stem}.agent.json" if split else f"{stem}.json"
files["valcore_judge.py"] = render_judge_module(version, agent_filename)
return ExportFilesResponse(files=files)


@router.post("/{id}/logfire/push")
async def push_dataset_to_logfire(id: str, body: LogfirePushRequest, store: StoreDep) -> dict:
"""Push a dataset and its rows to Logfire's hosted dataset store."""
dataset = store.get_dataset(id)
rows = store.list_rows(id)
return await push_dataset(
dataset,
rows,
name=body.name,
description=body.description,
on_conflict=body.on_conflict,
)
5 changes: 4 additions & 1 deletion src/valcore/api/routes/evaluators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict

from valcore import generator
from valcore import config, generator
from valcore.api.deps import get_store
from valcore.config_io import EvalPackage
from valcore.errors import ContractError, FrozenVersionError
Expand Down Expand Up @@ -405,6 +405,7 @@ async def generate(body: GenerateRequest, store: StoreDep) -> GeneratedConfig:
Calls an LLM and can take tens of seconds; the UI presents the returned config as an
editable draft saved as a version separately.
"""
config.require_gateway_key()
columns, label_schema = _resolve_seed(body, store)
return await generator.generate_config(
body.criteria,
Expand All @@ -422,6 +423,7 @@ async def generate_version(id: str, body: GenerateRequest, store: StoreDep) -> G
of seconds; the UI presents the returned config as an editable draft that the user
saves as a version separately.
"""
config.require_gateway_key()
store.get_evaluator(id)
columns, label_schema = _resolve_seed(body, store)
return await generator.generate_config(
Expand All @@ -439,4 +441,5 @@ async def refine_version(body: RefineRequest) -> RefinedConfig:
Calls an LLM and can take tens of seconds. The response includes ``changed_fields``
for the diff view; nothing is saved until the user submits a new version.
"""
config.require_gateway_key()
return await generator.refine_config(body.config, body.instruction)
16 changes: 16 additions & 0 deletions src/valcore/api/routes/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pydantic_ai import Agent
from sse_starlette.sse import EventSourceResponse

from valcore import config
from valcore.api.deps import get_store
from valcore.api.events import bus
from valcore.errors import ContractError
Expand Down Expand Up @@ -149,12 +150,17 @@ async def _run_to_completion(

Any exception escaping the runner is recorded on the run as ``FAILED`` so a run
is never left stuck in ``RUNNING``. The bus is always closed so subscribers stop.

Guards on ``config.require_gateway_key()`` before ever building an agent or calling
``execute_run``: ``defer_model_check=True`` lets ``build_agent`` succeed with no key,
so without this a keyless run would instead fail once per row inside ``_score_row``.
"""

async def on_event(event: RunEvent) -> None:
bus.publish(run_id, {"type": event.type, "run_id": run_id, "payload": event.payload})

try:
config.require_gateway_key()
agent: Agent | None = None
if agent_factory is not None:
run = await asyncio.to_thread(store.get_run, run_id)
Expand Down Expand Up @@ -231,7 +237,12 @@ async def create_run(body: RunCreate, store: StoreDep, agent_factory: AgentFacto
"""Create a run (freezing its version) and launch it in the background.

Returns immediately with status ``PENDING``; the run is never awaited in the handler.

Guards on ``config.require_gateway_key()`` before ``store.create_run``: a missing key
must surface synchronously as a ``ConfigError``, with no run ever persisted, rather than
as an asynchronous ``FAILED`` transition discovered by polling.
"""
config.require_gateway_key()
run = store.create_run(
kind=body.kind,
version_id=body.version_id,
Expand Down Expand Up @@ -397,7 +408,12 @@ async def retry_failed(id: str, store: StoreDep, agent_factory: AgentFactoryDep)

The run is reset to ``PENDING`` before relaunching so a client polling status is
never fooled by the previous run's stale terminal state.

Guards on ``config.require_gateway_key()`` before any other work: a missing key
must surface synchronously as a ``ConfigError``, with the prior run's status and
results untouched and no background task launched.
"""
config.require_gateway_key()
store.get_run(id)
failed_row_ids = store.failed_result_row_ids(id)
run = store.update_run_status(id, RunStatus.PENDING, error=None, finished_at=None)
Expand Down
70 changes: 70 additions & 0 deletions src/valcore/api/routes/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Read-only setup status: which configuration keys are effectively set.

Keys are written only via the CLI (``valcore config set-key`` and its Logfire siblings), so
there is no POST here and no key value ever crosses this endpoint -- only booleans, computed
from the same ``*_present`` helpers ``require_gateway_key`` relies on, so presence reflects an
exported env var exactly as it does everywhere else in the codebase.
"""

from fastapi import APIRouter
from pydantic import BaseModel

from valcore.config import (
gateway_key_present,
load_config,
logfire_api_key_present,
logfire_token_present,
)

router = APIRouter(prefix="/api/setup", tags=["setup"])


class KeyStatus(BaseModel):
"""Presence and static metadata for one configuration key. Never carries its value."""

name: str
set: bool
required: bool
label: str
command: str
purpose: str


class SetupOut(BaseModel):
"""The full setup status: one entry per documented configuration key."""

keys: list[KeyStatus]


@router.get("", response_model=SetupOut)
async def get_setup() -> SetupOut:
"""Report effective presence for the gateway key and both Logfire credentials."""
cfg = load_config()
return SetupOut(
keys=[
KeyStatus(
name="gateway_api_key",
set=gateway_key_present(cfg),
required=True,
label="Pydantic AI Gateway key",
command="valcore config set-key",
purpose="Runs evaluators and generates evaluators and datasets.",
),
KeyStatus(
name="logfire_token",
set=logfire_token_present(cfg),
required=False,
label="Logfire write token",
command="valcore config set-logfire-token",
purpose="Sends run traces to Logfire.",
),
KeyStatus(
name="logfire_api_key",
set=logfire_api_key_present(cfg),
required=False,
label="Logfire API key",
command="valcore config set-logfire-key",
purpose="Pushes datasets to Logfire's hosted store.",
),
]
)
Loading
Loading