diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 67fcaa1..af09ec0 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -2,6 +2,7 @@ name: pre-commit on: push: + branches: [main] pull_request: jobs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..221dd51 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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/ diff --git a/README.md b/README.md index d6c531a..3bd219b 100644 --- a/README.md +++ b/README.md @@ -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", [ diff --git a/SDK.md b/SDK.md index b9ca420..6bebc50 100644 --- a/SDK.md +++ b/SDK.md @@ -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` @@ -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, @@ -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). @@ -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. --- @@ -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 --- @@ -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"}, diff --git a/tests/test_add_questions.py b/tests/test_add_questions.py index 670a42b..6951996 100644 --- a/tests/test_add_questions.py +++ b/tests/test_add_questions.py @@ -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, diff --git a/tests/test_auth.py b/tests/test_auth.py index a28a1df..ac9c2bd 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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( @@ -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" diff --git a/tests/test_run_evaluation.py b/tests/test_run_evaluation.py index ab98d73..e699a93 100644 --- a/tests/test_run_evaluation.py +++ b/tests/test_run_evaluation.py @@ -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, diff --git a/uv.lock b/uv.lock index 3f62fd2..dd5ab71 100644 --- a/uv.lock +++ b/uv.lock @@ -1597,7 +1597,7 @@ wheels = [ [[package]] name = "verdikt-sdk" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/verdikt_sdk/auth.py b/verdikt_sdk/auth.py index e8c3b1b..1f4af61 100644 --- a/verdikt_sdk/auth.py +++ b/verdikt_sdk/auth.py @@ -8,7 +8,7 @@ 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__) @@ -16,20 +16,19 @@ 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"``. """ @@ -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) diff --git a/verdikt_sdk/client.py b/verdikt_sdk/client.py index c273d22..14c5867 100644 --- a/verdikt_sdk/client.py +++ b/verdikt_sdk/client.py @@ -14,7 +14,6 @@ from verdikt_sdk.models import ( AnswerWithCost, AppResponse, - CreateAppRequest, CreateDatasetRequest, CreateEvaluationRequest, DatasetEntry, @@ -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, diff --git a/verdikt_sdk/models.py b/verdikt_sdk/models.py index 5b29e68..0fc2139 100644 --- a/verdikt_sdk/models.py +++ b/verdikt_sdk/models.py @@ -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