Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: pre-commit

on:
push:
branches: [main]
pull_request:

jobs:
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: tests

on:
push:
branches: [main]
pull_request:

jobs:
tests:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Set up Python
run: uv python install 3.13

- name: Install dependencies
run: uv sync --group dev

- name: Run tests
run: uv run pytest -v tests/
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ client = VerdiktClient(
client_secret="your-client-secret",
)

# Register your app (idempotent — safe to call on every deploy)
await client.create_app(slug="my-app", name="My App")
# The app ("my-app") must already exist and this client must be bound to it,
# created beforehand in the Verdikt admin UI — the SDK does not create apps.

# Sync questions to the dataset (idempotent)
await client.add_questions("my-app", [
Expand Down
36 changes: 18 additions & 18 deletions SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ Four additions needed before the SDK can be built:

This replaces the need to fetch all apps and filter client-side.

### 2. `GET /.well-known`
Returns the Zitadel issuer URL so the SDK can discover it from `base_url` alone.
### 2. `GET /.well-known/openid-configuration`
Verdikt's own discovery document — publishes the token endpoint so the SDK can
discover it from `base_url` alone.

```json
{ "issuer": "https://my-zitadel.example.com" }
{ "token_endpoint": "https://verdikt.example.com/auth/token" }
```

### 3. `GET /v1/app/{app_id}/datasets/hashes`
Expand Down Expand Up @@ -56,8 +57,6 @@ class EvaluationClient:
client_secret: str, # Zitadel machine user client secret
) -> None: ...

def create_app(self, slug: str, name: str) -> None: ...

def add_questions(
self,
app_slug: str,
Expand All @@ -76,13 +75,14 @@ class EvaluationClient:

---

## Method details
## Prerequisite: the app must already exist

### `create_app(slug, name)`
Idempotent — safe to call on every deploy.
The SDK does **not** create apps. A machine client is scoped to the apps it is
bound to and cannot create new ones. Before using the SDK, an admin must create
the app and bind this client to it in the Verdikt admin UI. The SDK then
references the app by its slug.

1. `GET /v1/app/by-slug/{slug}` → if 200, app exists → no-op
2. If 404 → `POST /v1/app` with `{ "slug": slug, "name": name }`
## Method details

### `add_questions(app_slug, questions)`
Idempotent — safe to call on every deploy. Uses SHA-256 of the question text as the match key so full text is never compared directly (questions can be long).
Expand Down Expand Up @@ -116,14 +116,15 @@ Idempotent — safe to call on every deploy. Uses SHA-256 of the question text a

## Auth

Uses **OAuth2 client credentials grant** against Zitadel.
Uses the **OAuth2 client credentials grant** against Verdikt itself (it is its
own machine-token issuer).

Flow on first API call:
1. `GET {base_url}/.well-known` → get `issuer`
2. `POST {issuer}/oauth/v2/token` with `grant_type=client_credentials`, `client_id`, `client_secret`
1. `GET {base_url}/.well-known/openid-configuration` → get `token_endpoint`
2. `POST {token_endpoint}` with `grant_type=client_credentials`, HTTP Basic `client_id`/`client_secret`
3. Cache the token; refresh automatically when `expires_in` is reached

The `issuer` and token are cached on the client instance — no repeated discovery calls.
The token endpoint and token are cached on the client instance — no repeated discovery calls.

---

Expand All @@ -137,7 +138,7 @@ All three methods resolve `app_slug` → `app_id` via `GET /v1/app/by-slug/{slug

- Lowercase, alphanumeric, hyphens only — e.g. `"my-app"`, `"gpt-wrapper-v2"`
- Enforced by the API (422 if invalid format)
- Chosen by the integrator at `create_app` time; stable forever
- Chosen when the app is created in the admin UI; stable forever

---

Expand All @@ -159,9 +160,8 @@ client = EvaluationClient(
client_secret="...",
)

# Idempotent setup — safe to call on every deploy
client.create_app(slug="my-app", name="My App")

# The app ("my-app") must already exist and this client must be bound to it
# (created in the admin UI). The SDK does not create apps.
client.add_questions("my-app", [
{"question": "What is the capital of France?", "human_answer": "Paris"},
{"question": "What is 2 + 2?", "human_answer": "4"},
Expand Down
2 changes: 0 additions & 2 deletions tests/test_add_questions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ def _build_handler(captured: dict) -> httpx.MockTransport:

def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url.endswith("/.well-known"):
return httpx.Response(200, json={"issuer": "http://issuer.test"})
if url.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
Expand Down
6 changes: 2 additions & 4 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@


def _build_handler(captured: dict) -> httpx.MockTransport:
"""Mock IdP discovery + token endpoint, capturing the token request form."""
"""Mock Verdikt discovery + token endpoint, capturing the token request form."""

def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url.endswith("/.well-known"):
return httpx.Response(200, json={"issuer": "http://issuer.test"})
if url.endswith("/.well-known/openid-configuration"):
captured["discovery_url"] = url
return httpx.Response(
Expand Down Expand Up @@ -59,7 +57,7 @@ async def test_token_endpoint_is_discovered_from_openid_configuration():
# Assert — used the discovered endpoint, not a hardcoded path
assert token == "tok"
assert captured["discovery_url"] == (
"http://issuer.test/.well-known/openid-configuration"
"http://verdikt.test/.well-known/openid-configuration"
)
assert captured["token_url"] == "http://issuer.test/custom/token"

Expand Down
2 changes: 0 additions & 2 deletions tests/test_run_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ def _build_handler(captured: dict) -> httpx.MockTransport:

def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url.endswith("/.well-known"):
return httpx.Response(200, json={"issuer": "http://issuer.test"})
if url.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 10 additions & 23 deletions verdikt_sdk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,27 @@
import httpx

from verdikt_sdk.http import raise_for_status
from verdikt_sdk.models import OpenIDConfiguration, TokenResponse, WellKnown
from verdikt_sdk.models import OpenIDConfiguration, TokenResponse

logger = logging.getLogger(__name__)

_DISCOVERY_TTL = 86400


class TokenAuth:
"""OAuth2 client-credentials token acquisition against any OIDC provider.
"""OAuth2 client-credentials token acquisition against the Verdikt service.

The token endpoint is discovered from the provider's standard
``/.well-known/openid-configuration`` document, so this works with any
OIDC-compliant IdP (Zitadel, Keycloak, Okta, Auth0, ...) — not just Zitadel.
Verdikt is its own machine-token issuer: the token endpoint is discovered
from its ``{base_url}/.well-known/openid-configuration`` document and the
returned bearer token is an opaque Verdikt-issued credential.

Args:
base_url: Base URL of the evaluation service (used to discover the issuer).
client_id: Machine/service-account client ID registered in the IdP.
base_url: Base URL of the evaluation service (= the token issuer).
client_id: Machine-client ID (minted on an app's detail page).
client_secret: The matching client secret.
http: Shared ``httpx.AsyncClient`` instance.
audience: Optional ``audience`` to request, so the issued token's ``aud``
matches what the Verdikt backend verifies (``OIDC_AUDIENCE``). Sent
as the ``audience`` token-request parameter when set.
audience: Optional ``audience`` token-request parameter; unused by
Verdikt itself, kept for OAuth2 compatibility.
scope: OAuth scopes to request. Defaults to ``"openid profile"``.
"""

Expand All @@ -49,30 +48,18 @@ def __init__(
self._audience = audience
self._scope = scope

self._issuer: str | None = None
self._token_endpoint: str | None = None
self._token: str | None = None
self._token_expires_at: float = 0.0
self._discovery_expires_at: float = 0.0

async def _discover_issuer(self) -> str:
if self._issuer is not None and time.monotonic() < self._discovery_expires_at:
return self._issuer
logger.debug("Fetching issuer from %s/.well-known", self._base_url)
resp = await self._http.get(f"{self._base_url}/.well-known")
raise_for_status(resp)
self._issuer = WellKnown.model_validate(resp.json()).issuer
logger.debug("Discovered issuer: %s", self._issuer)
return self._issuer

async def _discover_token_endpoint(self) -> str:
if (
self._token_endpoint is not None
and time.monotonic() < self._discovery_expires_at
):
return self._token_endpoint
issuer = await self._discover_issuer()
url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
url = f"{self._base_url.rstrip('/')}/.well-known/openid-configuration"
logger.debug("Fetching OIDC configuration from %s", url)
resp = await self._http.get(url)
raise_for_status(resp)
Expand Down
39 changes: 0 additions & 39 deletions verdikt_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from verdikt_sdk.models import (
AnswerWithCost,
AppResponse,
CreateAppRequest,
CreateDatasetRequest,
CreateEvaluationRequest,
DatasetEntry,
Expand Down Expand Up @@ -77,44 +76,6 @@ async def _resolve_slug(self, app_slug: str) -> int:
logger.debug("Resolved slug '%s' -> app_id %d", app_slug, app_id)
return app_id

async def create_app(self, slug: str, name: str) -> None:
"""Idempotent — safe to call on every deploy.

Checks whether the app already exists by slug; creates it only when it
does not.

Args:
slug: URL-safe identifier for the app (lowercase, hyphens only).
name: Human-readable display name.
"""
logger.info("Ensuring app '%s' exists", slug)
if slug in self._slug_cache:
logger.info("App '%s' already exists, skipping creation", slug)
return

headers = await self._auth.headers()
resp = await self._http.get(
f"{self.base_url}/v1/app/by-slug/{slug}",
headers=headers,
)
if resp.status_code == 200:
logger.info("App '%s' already exists, skipping creation", slug)
self._slug_cache[slug] = AppResponse.model_validate(resp.json()).id
return
if resp.status_code != 404:
raise_for_status(resp)

logger.info("Creating app '%s' (%s)", slug, name)
body = CreateAppRequest(slug=slug, name=name)
create_resp = await self._http.post(
f"{self.base_url}/v1/app",
json=body.model_dump(),
headers=headers,
)
raise_for_status(create_resp)
self._slug_cache[slug] = AppResponse.model_validate(create_resp.json()).id
logger.info("App '%s' created", slug)

async def add_questions(
self,
app_slug: str,
Expand Down
8 changes: 1 addition & 7 deletions verdikt_sdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,8 @@
# ---------------------------------------------------------------------------


class WellKnown(BaseModel):
"""Response from the Verdikt service ``GET /.well-known`` (the issuer)."""

issuer: str


class OpenIDConfiguration(BaseModel):
"""Subset of the IdP's ``GET {issuer}/.well-known/openid-configuration``."""
"""Subset of ``GET {base_url}/.well-known/openid-configuration``."""

token_endpoint: str

Expand Down
Loading