From 5c6dcfc3a9f7ee7b3de8e69423649292453fcbe3 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sat, 10 Oct 2026 22:40:54 +0200 Subject: [PATCH 01/34] docs: add a table of contents to the root and docs/ markdown files (#1063) (#1064) Each long engineering doc gets an H2/H3 table of contents above its first section, wrapped in / markers so it can be regenerated. Every anchor matches GitHub's rendered heading ids. Headings are unchanged, so existing anchor links keep working. Co-authored-by: Claude Opus 5.5 --- ARCHITECTURE.md | 11 ++++++++ CONTRIBUTING.md | 28 +++++++++++++++++++ MIGRATION.md | 54 ++++++++++++++++++++++++++++++++++++ README.md | 10 +++++++ SECURITY.md | 10 +++++++ docs/ci.md | 25 +++++++++++++++++ docs/python-support.md | 9 ++++++ docs/releasing.md | 16 +++++++++++ docs/unsupported-boundary.md | 8 ++++++ docs/verified-apis.md | 30 ++++++++++++++++++++ 10 files changed, 201 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf8dd4f0..1a52fd0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -44,6 +44,17 @@ owns. The gateway — not the SDK — enforces policy. --- + +**Contents** + +- [Layered architecture](#layered-architecture) + - [How the pieces connect](#how-the-pieces-connect) +- [Verification discipline](#verification-discipline) +- [Error-taxonomy design (BG §1.2)](#error-taxonomy-design-bg-12) +- [Adapter support depth (BG §1.8)](#adapter-support-depth-bg-18) +- [Related documents](#related-documents) + + ## Layered architecture The package is a strict stack. Higher layers depend on lower layers; **no lower diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1590cca8..8bf4bab8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,34 @@ noted. There is no Makefile — every command runs directly. --- + +**Contents** + +- [1. Branch, PR & release workflow](#1-branch-pr--release-workflow) + - [Who can contribute: internal vs external](#who-can-contribute-internal-vs-external) + - [The issue is the plan](#the-issue-is-the-plan) + - [Branch model](#branch-model) + - [The lifecycle](#the-lifecycle) + - [The pre-PR gate](#the-pre-pr-gate) + - [The PR](#the-pr) + - [Architecture decision records](#architecture-decision-records) + - [Merging](#merging) +- [2. Testing strategy](#2-testing-strategy) + - [tests/unit/ — the framework-free gate](#testsunit--the-framework-free-gate) + - [tests/typecheck/ — downstream static contracts](#teststypecheck--downstream-static-contracts) + - [The conformance kit — "never a silent skip"](#the-conformance-kit--never-a-silent-skip) + - [Adding a framework adapter: the integration checklist](#adding-a-framework-adapter-the-integration-checklist) + - [Fixture-driven tests — captures, not conveniences](#fixture-driven-tests--captures-not-conveniences) + - [local_gateway and sandbox — infra-gated, clean-skip by default](#local_gateway-and-sandbox--infra-gated-clean-skip-by-default) + - [scripts/verify_frameworks.py — signatures, outside pytest](#scriptsverify_frameworkspy--signatures-outside-pytest) + - [Commands](#commands) +- [3. Coding conventions](#3-coding-conventions) + - [Rule-to-enforcement map](#rule-to-enforcement-map) + - [Logging](#logging) +- [4. Docs-sync rule](#4-docs-sync-rule) + - [The one deliberate duplication — and its drift risk](#the-one-deliberate-duplication--and-its-drift-risk) + + ## 1. Branch, PR & release workflow ### Who can contribute: internal vs external diff --git a/MIGRATION.md b/MIGRATION.md index 92224aa8..dbc61f41 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,59 @@ # Migration guide + +**Contents** + +- [0.1.2: public API surface, removed control plane and typed errors](#012-public-api-surface-removed-control-plane-and-typed-errors) + - [1. donkey_kit.provisioning and donkey_kit.governance are gone](#1-donkey_kitprovisioning-and-donkey_kitgovernance-are-gone) + - [2. Hidden CLI commands validate, plan, apply, drift, lint, generate are removed](#2-hidden-cli-commands-validate-plan-apply-drift-lint-generate-are-removed) + - [3. Three errors are removed](#3-three-errors-are-removed) + - [4. Blocked-surface types moved to donkey_kit.experimental](#4-blocked-surface-types-moved-to-donkey_kitexperimental) + - [5. Two renamed names, with deprecated aliases](#5-two-renamed-names-with-deprecated-aliases) + - [6. run_context() is deprecated](#6-run_context-is-deprecated) + - [7. Removed names](#7-removed-names) + - [8. Internal names renamed without an alias](#8-internal-names-renamed-without-an-alias) + - [9. pydantic and pyyaml are no longer installed for you](#9-pydantic-and-pyyaml-are-no-longer-installed-for-you) + - [10. The dev, mcp and a2a extras are gone, and [all] installs no test runner](#10-the-dev-mcp-and-a2a-extras-are-gone-and-all-installs-no-test-runner) + - [11. The conformance plugin's --agent is now --donkey-agent](#11-the-conformance-plugins---agent-is-now---donkey-agent) + - [12. Donkey() no longer installs the global OpenTelemetry provider](#12-donkey-no-longer-installs-the-global-opentelemetry-provider) + - [13. The user config file is merged beneath the project files](#13-the-user-config-file-is-merged-beneath-the-project-files) + - [14. Donkey.from_env() accepts every field](#14-donkeyfrom_env-accepts-every-field) + - [15. A 502 or 504 on a model call is no longer retried by default](#15-a-502-or-504-on-a-model-call-is-no-longer-retried-by-default) + - [16. Errors leaving donkey.run() and @donkey.governed are typed](#16-errors-leaving-donkeyrun-and-donkeygoverned-are-typed) + - [17. A request-rate-limit 429 is RequestRateLimitExceeded](#17-a-request-rate-limit-429-is-requestratelimitexceeded) + - [Shipped simulator fixtures moved into the package (not breaking)](#shipped-simulator-fixtures-moved-into-the-package-not-breaking) + - [New exports (not breaking)](#new-exports-not-breaking) + - [LlamaIndex, Agent Framework and ADK model() report last_call (not breaking)](#llamaindex-agent-framework-and-adk-model-report-last_call-not-breaking) +- [0.1.1: credential handling, endpoint trust and printed output](#011-credential-handling-endpoint-trust-and-printed-output) + - [1. Non-loopback http:// endpoints are refused](#1-non-loopback-http-endpoints-are-refused) + - [2. A URL from the project's config files only gets credentials from those files](#2-a-url-from-the-projects-config-files-only-gets-credentials-from-those-files) + - [3. .donkey-kit.local.toml is now read](#3-donkey-kitlocaltoml-is-now-read) + - [4. Each credential stays on its own plane](#4-each-credential-stays-on-its-own-plane) + - [5. Cost-tag headers are off by default](#5-cost-tag-headers-are-off-by-default) + - [6. donkey doctor output](#6-donkey-doctor-output) + - [7. Some configurable header names are refused](#7-some-configurable-header-names-are-refused) + - [8. Config files that link outside the working directory are refused](#8-config-files-that-link-outside-the-working-directory-are-refused) + - [9. URL overrides passed to factories must use https://](#9-url-overrides-passed-to-factories-must-use-https) + - [10. Credentials go only to checked endpoints](#10-credentials-go-only-to-checked-endpoints) + - [11. LlamaIndex, MS Agent Framework and ADK's model() send through the SDK's client](#11-llamaindex-ms-agent-framework-and-adks-model-send-through-the-sdks-client) + - [12. PIIDetected messages no longer contain the flagged values](#12-piidetected-messages-no-longer-contain-the-flagged-values) + - [13. repr() / str() of DonkeyConfig omit the secrets](#13-repr--str-of-donkeyconfig-omit-the-secrets) + - [14. Printed connection_kwargs() show '*' for secrets](#14-printed-connection_kwargs-show--for-secrets) + - [15. typed_refusals() no longer chains the LangChain error](#15-typed_refusals-no-longer-chains-the-langchain-error) + - [16. An Agent Kill Switch block raises AgentKilled](#16-an-agent-kill-switch-block-raises-agentkilled) + - [17. donkey-kit[all] leaves out the seven connection_kwargs() frameworks](#17-donkey-kitall-leaves-out-the-seven-connection_kwargs-frameworks) +- [agent-fabric → donkey-kit (the DDK rebrand)](#agent-fabric--donkey-kit-the-ddk-rebrand) + - [At a glance](#at-a-glance) + - [1. Install](#1-install) + - [2. Imports and the client class](#2-imports-and-the-client-class) + - [3. CLI](#3-cli) + - [4. Configuration file](#4-configuration-file) + - [5. Environment variables](#5-environment-variables) + - [6. Testing surfaces](#6-testing-surfaces) + - [7. OpenTelemetry (breaking) — the fabric. → donkey. namespace flip](#7-opentelemetry-breaking--the-fabric--donkey-namespace-flip) + - [What stays the same](#what-stays-the-same) + + ## 0.1.2: public API surface, removed control plane and typed errors Changes since `0.1.1` that can affect existing code. 0.1.2 defines the public diff --git a/README.md b/README.md index 4ffc7dfc..269f0929 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,16 @@ access — from your own agent framework, in your own IDE, without adopting Mule > dropped in the first minor release after its end of life. See > [`docs/python-support.md`](docs/python-support.md). + +**Contents** + +- [Documentation](#documentation) +- [Install](#install) +- [Framework support](#framework-support) +- [What's verified (verification discipline)](#whats-verified-verification-discipline) +- [Conformance exemptions](#conformance-exemptions) + + ## Documentation Two audiences, two doc sets: diff --git a/SECURITY.md b/SECURITY.md index d19786c9..4c92d121 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,15 @@ # Security policy + +**Contents** + +- [Supported versions](#supported-versions) +- [Reporting a vulnerability](#reporting-a-vulnerability) + - [What is in scope](#what-is-in-scope) +- [What to expect](#what-to-expect) +- [Disclosure process](#disclosure-process) + + ## Supported versions `donkey-kit` is pre-1.0. Security fixes land on `develop` and ship in the next diff --git a/docs/ci.md b/docs/ci.md index c6297e67..2a718c04 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -13,6 +13,31 @@ a release reaches PyPI, see [`releasing.md`](releasing.md). For the dependency policy behind the locks and the nightly run, see [ADR 0007](adr/0007-dependency-policy.md). + +**Contents** + +- [The signals](#the-signals) + - [ci.yml: the PR gate](#ciyml-the-pr-gate) + - [nightly-matrix.yml: the canary](#nightly-matrixyml-the-canary) + - [live-contract-check.yml: the gateway contract](#live-contract-checkyml-the-gateway-contract) +- [Scenario maps](#scenario-maps) + - [Feature or fix (normal path)](#feature-or-fix-normal-path) + - [Hotfix (into main, then back to develop)](#hotfix-into-main-then-back-to-develop) + - [Error scenarios and recovery](#error-scenarios-and-recovery) +- [Conventions](#conventions) + - [1. PR CI resolves from a lock; the nightly run resolves fresh. In place (#982)](#1-pr-ci-resolves-from-a-lock-the-nightly-run-resolves-fresh-in-place-982) + - [2. One required status check: ci-ok. In place (#755)](#2-one-required-status-check-ci-ok-in-place-755) + - [3. CI runs on develop, not just on PRs. In place (#755)](#3-ci-runs-on-develop-not-just-on-prs-in-place-755) + - [4. Every scheduled workflow alerts on failure. Partly in place](#4-every-scheduled-workflow-alerts-on-failure-partly-in-place) + - [5. One task runner mirrors CI. Target (#765)](#5-one-task-runner-mirrors-ci-target-765) + - [6. Jobs are bounded and superseded runs are cancelled. In place (#761)](#6-jobs-are-bounded-and-superseded-runs-are-cancelled-in-place-761) + - [7. Least-privilege workflows. Partly in place (#757, #771)](#7-least-privilege-workflows-partly-in-place-757-771) + - [8. The site is built on PRs, on a supported Node. Target (#759)](#8-the-site-is-built-on-prs-on-a-supported-node-target-759) + - [9. Release gates are code, not prose. Target (#767)](#9-release-gates-are-code-not-prose-target-767) + - [10. Workflow names follow one style. In place (#1009)](#10-workflow-names-follow-one-style-in-place-1009) +- [Changing a workflow](#changing-a-workflow) + + ## The signals Each workflow answers exactly one question. Mixing two signals in one job makes diff --git a/docs/python-support.md b/docs/python-support.md index 22c062be..43ed21b2 100644 --- a/docs/python-support.md +++ b/docs/python-support.md @@ -4,6 +4,15 @@ and that CI tests. This page says which versions those are and when a version is added or dropped (#745). + +**Contents** + +- [Supported versions](#supported-versions) +- [The rules](#the-rules) +- [Python 3.10](#python-310) +- [Raising the floor: the checklist](#raising-the-floor-the-checklist) + + ## Supported versions | Python | Supported | CPython end of life | diff --git a/docs/releasing.md b/docs/releasing.md index 3a78cf20..b1d86d04 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -13,6 +13,22 @@ promotion, is covered under [Hotfix releases](#hotfix-releases). Everything below is self-contained — you should not need any other document to cut a release. + +**Contents** + +- [Versioning & naming](#versioning--naming) + - [The pre-release ladder](#the-pre-release-ladder) + - [Tags](#tags) + - [The version string lives in two files](#the-version-string-lives-in-two-files) +- [How a release reaches PyPI](#how-a-release-reaches-pypi) +- [Hotfix releases](#hotfix-releases) + - [Merge mechanics](#merge-mechanics) + - [Docs hotfix: no version](#docs-hotfix-no-version) + - [Package hotfix: next patch, milestones shift](#package-hotfix-next-patch-milestones-shift) +- [The public API surface semver governs](#the-public-api-surface-semver-governs) +- [One-time human setup: register the Trusted Publisher](#one-time-human-setup-register-the-trusted-publisher) + + ## Versioning & naming `donkey-kit` version strings are **PEP 440** (not raw SemVer), so they normalise diff --git a/docs/unsupported-boundary.md b/docs/unsupported-boundary.md index 596b1130..717af96e 100644 --- a/docs/unsupported-boundary.md +++ b/docs/unsupported-boundary.md @@ -31,6 +31,14 @@ Classification: | Successful-response budget window in `x-llm-proxy-ratelimit` prose | `core.budget.Budget` | **Documented but no SLA for third-party use** | Captured from a real Model Proxy and parsed fail-open: an absent or changed sentence is ignored, never fatal. The exact prose contract is `VERIFIED (LIVE)` in `verified-apis.md` §4 but is not stated in the public policy page. Owner: DDK maintainers; re-capture on gateway-policy changes. | | Gateway identity and routing response metadata: `x-request-id`, `x-envoy-decorator-operation`, and `x-llm-proxy-{routing-type,routing-fallback,llm-provider,llm-model}` | `core.lastcall.LastCall`; `core.transport.DonkeyAsyncClient` | **Documented but no SLA for third-party use** | Captured from a real Model Proxy (`verified-apis.md` §2 and §3). Parsers are fail-open: missing or unrecognised values become `None`, and only an explicit `routing-fallback: true` changes retry behavior. Owner: DDK maintainers; contract changes require new fixtures before parser changes. | + +**Contents** + +- [Deliberately outside the shipping boundary](#deliberately-outside-the-shipping-boundary) +- [Local Mode is not a supported deployment target](#local-mode-is-not-a-supported-deployment-target) +- [Undocumented surfaces](#undocumented-surfaces) + + ## Deliberately outside the shipping boundary - Exchange search and asset resolution, API Manager governed-state reads, MCP diff --git a/docs/verified-apis.md b/docs/verified-apis.md index 93346969..8942c20b 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -63,6 +63,36 @@ its status here flips to `VERIFIED`. --- + +**Contents** + +- [1. Anypoint control plane](#1-anypoint-control-plane) +- [2. LLM Proxy (data plane) — LIVE-VERIFIED 2026-08-28](#2-llm-proxy-data-plane--live-verified-2026-08-28) + - [Per-upstream route matrix (#894) — LIVE-VERIFIED 2026-10-08](#per-upstream-route-matrix-894--live-verified-2026-10-08) +- [3. Token attribution headers (highest-priority unknown, verification discipline)](#3-token-attribution-headers-highest-priority-unknown-verification-discipline) +- [4. Policy rejection response shapes (capture as fixtures, BG §1.5)](#4-policy-rejection-response-shapes-capture-as-fixtures-bg-15) +- [5. MCP Bridge / Agent Network provisioning — gates whether §5 is viable at all](#5-mcp-bridge--agent-network-provisioning--gates-whether-5-is-viable-at-all) +- [6. Governance / local-mode (the Verification milestone)](#6-governance--local-mode-the-verification-milestone) +- [7. Publication / Exchange (BG §2.5)](#7-publication--exchange-bg-25) +- [8. Framework APIs (BG §1.8) — re-verify every constructor](#8-framework-apis-bg-18--re-verify-every-constructor) + - [8.1 Known upstream incompatibilities (floors, not ceilings)](#81-known-upstream-incompatibilities-floors-not-ceilings) + - [8.2 Known upstream runtime limitations](#82-known-upstream-runtime-limitations) + - [8.3 Extras floors (lowest verified versions)](#83-extras-floors-lowest-verified-versions) +- [9. MCP tool binding classes (BG §2.7) — verify each name](#9-mcp-tool-binding-classes-bg-27--verify-each-name) +- [10. Descriptor-derivation attributes (BG §2.5) — semi-public, put in nightly matrix](#10-descriptor-derivation-attributes-bg-25--semi-public-put-in-nightly-matrix) +- [11. A2D platform MCP tools — shapes captured 2026-08-28 (NOT the direct Anypoint REST API)](#11-a2d-platform-mcp-tools--shapes-captured-2026-08-28-not-the-direct-anypoint-rest-api) + - [Open design questions surfaced by the probe (need a platform-team decision)](#open-design-questions-surfaced-by-the-probe-need-a-platform-team-decision) +- [12. Agent-donkey CLI plugin — direct REST contract (static analysis, 2026-08-28)](#12-agent-donkey-cli-plugin--direct-rest-contract-static-analysis-2026-08-28) + - [12.1 Auth (transport dependency anypoint-cli-command/lib/)](#121-auth-transport-dependency-anypoint-cli-commandlib) + - [12.2 Control-plane attribution / correlation headers (CLI request headers)](#122-control-plane-attribution--correlation-headers-cli-request-headers) + - [12.3 Exchange publish / read (dist/utils/exchange.js, dist/utils/facets/asset-facet.js)](#123-exchange-publish--read-distutilsexchangejs-distutilsfacetsasset-facetjs) + - [12.4 Agent Network gateway setup + Private Space (dist/utils/uris.js, gateway.js)](#124-agent-network-gateway-setup--private-space-distutilsurisjs-gatewayjs) + - [12.5 API Manager governance + app deploy (dist/utils/facets/*)](#125-api-manager-governance--app-deploy-distutilsfacets) + - [12.6 LLM proxy — how governance is actually wired](#126-llm-proxy--how-governance-is-actually-wired) + - [12.7 Agent Network project format (dist/commands/agent-network/project/create.js, templates/)](#127-agent-network-project-format-distcommandsagent-networkprojectcreatejs-templates) + - [12.8 Unblocking guidance (verification discipline)](#128-unblocking-guidance-verification-discipline) + + ## 1. Anypoint control plane CLI-verified rows were confirmed against the real sandbox org From 2b2e4da27b09fb6715dd4044bcdbbb39c9b409e5 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:02:01 +0200 Subject: [PATCH 02/34] style: apply ruff format across python/ (#1073) Part of #765 --- python/examples/anthropic/README.md | 4 +- python/examples/crewai/README.md | 4 +- python/examples/langgraph/collector.py | 6 +- python/examples/openai_agents/main.py | 4 +- python/scripts/verify_frameworks.py | 110 +++++++++++++----- python/src/donkey_kit/cli/_app.py | 3 +- python/src/donkey_kit/cli/doctor.py | 57 +++++---- python/src/donkey_kit/cli/init.py | 6 +- python/src/donkey_kit/cli/mock.py | 2 +- python/src/donkey_kit/cli/test.py | 2 +- python/src/donkey_kit/conformance/gateway.py | 12 +- python/src/donkey_kit/conformance/harness.py | 8 +- python/src/donkey_kit/conformance/plugin.py | 15 ++- python/src/donkey_kit/conformance/suite.py | 18 +-- python/src/donkey_kit/core/__init__.py | 1 + python/src/donkey_kit/core/config.py | 10 +- python/src/donkey_kit/core/endpoints.py | 3 +- python/src/donkey_kit/core/errors.py | 14 +-- python/src/donkey_kit/core/refusals.py | 4 +- .../src/donkey_kit/core/transport/headers.py | 4 +- .../src/donkey_kit/core/transport/observe.py | 4 +- python/src/donkey_kit/donkey.py | 4 +- .../src/donkey_kit/integrations/__init__.py | 64 +++++++--- python/src/donkey_kit/integrations/_base.py | 4 +- python/src/donkey_kit/llm/__init__.py | 1 + python/src/donkey_kit/llm/client.py | 4 +- python/src/donkey_kit/registry/__init__.py | 1 + python/src/donkey_kit/registry/introspect.py | 4 +- python/src/donkey_kit/registry/publication.py | 6 +- python/src/donkey_kit/simulator/app.py | 5 +- python/src/donkey_kit/simulator/fixtures.py | 17 +-- python/src/donkey_kit/simulator/scenarios.py | 8 +- python/src/donkey_kit/tools/__init__.py | 1 + python/src/donkey_kit/tools/session.py | 3 +- python/tests/conformance/contract_drivers.py | 75 +++++++++--- python/tests/conformance/contract_gateway.py | 51 ++++++-- .../conformance/test_adapter_contract.py | 2 +- .../conformance/test_langgraph_conformance.py | 4 +- .../conformance/test_stock_openai_honesty.py | 4 +- .../conformance/test_transport_exemptions.py | 4 +- python/tests/typecheck/asset_type_contract.py | 4 +- python/tests/unit/_anthropic_wire.py | 65 ++++++++--- python/tests/unit/test_adapter_ergonomics.py | 4 +- .../tests/unit/test_adapter_openai3_bridge.py | 4 +- python/tests/unit/test_architecture.py | 4 +- python/tests/unit/test_budget.py | 6 +- python/tests/unit/test_cli_doctor.py | 15 ++- python/tests/unit/test_cli_mock.py | 12 +- python/tests/unit/test_config.py | 20 +--- .../tests/unit/test_config_endpoint_trust.py | 22 +--- python/tests/unit/test_config_precedence.py | 4 +- python/tests/unit/test_config_validation.py | 16 ++- python/tests/unit/test_conformance.py | 7 +- python/tests/unit/test_conformance_plugin.py | 12 +- python/tests/unit/test_cost.py | 8 +- .../unit/test_credential_binding_planes.py | 8 +- python/tests/unit/test_credential_planes.py | 12 +- .../unit/test_data_plane_only_headers.py | 4 +- python/tests/unit/test_decorators.py | 2 +- python/tests/unit/test_default_runtime.py | 12 +- .../tests/unit/test_docs_from_env_kwargs.py | 4 +- python/tests/unit/test_doctor.py | 29 ++--- python/tests/unit/test_donkey_surface.py | 16 +-- python/tests/unit/test_error_taxonomy.py | 8 +- python/tests/unit/test_errors.py | 36 ++++-- python/tests/unit/test_framework_retries.py | 6 +- .../unit/test_gemini_inbound_contract.py | 4 +- .../unit/test_langgraph_sync_transport.py | 4 +- .../unit/test_langgraph_typed_refusals.py | 2 +- python/tests/unit/test_lastcall.py | 12 +- .../unit/test_llm_client_openai3_injection.py | 4 +- python/tests/unit/test_llm_proxy_contract.py | 6 +- python/tests/unit/test_logging.py | 1 - .../unit/test_missing_framework_error.py | 6 +- .../tests/unit/test_model_wallet_contract.py | 10 +- .../test_openai_gemini_stream_contract.py | 4 +- python/tests/unit/test_product_description.py | 8 +- python/tests/unit/test_public_api_surface.py | 1 - python/tests/unit/test_pure_logic.py | 2 +- python/tests/unit/test_refusal_bridge.py | 9 +- python/tests/unit/test_semantic_cache.py | 1 + python/tests/unit/test_sensitive_output.py | 6 +- python/tests/unit/test_simulate.py | 4 +- python/tests/unit/test_simulator_app.py | 16 +-- python/tests/unit/test_simulator_fixtures.py | 10 +- .../unit/test_simulator_scenarios_app.py | 12 +- python/tests/unit/test_transport.py | 31 ++--- .../tests/unit/test_transport_both_clients.py | 27 ++--- .../tests/unit/test_transport_proxy_mounts.py | 9 +- .../test_verification_blocked_surfaces.py | 8 +- python/tests/unit/test_wire_names.py | 4 +- 91 files changed, 592 insertions(+), 502 deletions(-) diff --git a/python/examples/anthropic/README.md b/python/examples/anthropic/README.md index 0824efe8..06a8519e 100644 --- a/python/examples/anthropic/README.md +++ b/python/examples/anthropic/README.md @@ -61,8 +61,8 @@ import httpx2 # anthropic 0.x is built on httpx: use httpx.AsyncClient there from anthropic import AsyncAnthropic c = AsyncAnthropic( - base_url=DONKEY_LLM_PROXY_URL, # a Format=Anthropic proxy: the native /v1/messages route (docs/verified-apis.md §2) - api_key="unused", # proxy enforces client_id/client_secret headers + base_url=DONKEY_LLM_PROXY_URL, # a Format=Anthropic proxy: the native /v1/messages route (docs/verified-apis.md §2) + api_key="unused", # proxy enforces client_id/client_secret headers default_headers={ "client_id": DONKEY_LLM_PROXY_CLIENT_ID, "client_secret": DONKEY_LLM_PROXY_CLIENT_SECRET, diff --git a/python/examples/crewai/README.md b/python/examples/crewai/README.md index e52e859a..5a47dce4 100644 --- a/python/examples/crewai/README.md +++ b/python/examples/crewai/README.md @@ -54,9 +54,9 @@ governed connection values (BG §1.8): from crewai import LLM model = LLM( - model="openai/gpt-4o", # routes to CrewAI's native OpenAI provider + model="openai/gpt-4o", # routes to CrewAI's native OpenAI provider base_url=DONKEY_LLM_PROXY_URL, - api_key="unused", # proxy enforces client_id/client_secret headers + api_key="unused", # proxy enforces client_id/client_secret headers extra_headers={ "client_id": DONKEY_LLM_PROXY_CLIENT_ID, "client_secret": DONKEY_LLM_PROXY_CLIENT_SECRET, diff --git a/python/examples/langgraph/collector.py b/python/examples/langgraph/collector.py index 9033310f..27e3263f 100644 --- a/python/examples/langgraph/collector.py +++ b/python/examples/langgraph/collector.py @@ -80,11 +80,7 @@ def do_POST(self) -> None: body = self.rfile.read(length) if length else b"" if self.path.rstrip("/").endswith("/v1/traces"): sink.record(body) - payload = ( - ExportTraceServiceResponse().SerializeToString() - if _PROTO_OK - else b"" - ) + payload = ExportTraceServiceResponse().SerializeToString() if _PROTO_OK else b"" self.send_response(200) self.send_header("Content-Type", "application/x-protobuf") self.send_header("Content-Length", str(len(payload))) diff --git a/python/examples/openai_agents/main.py b/python/examples/openai_agents/main.py index 1e8c4545..cf862174 100644 --- a/python/examples/openai_agents/main.py +++ b/python/examples/openai_agents/main.py @@ -67,9 +67,7 @@ async def run(self, text: str) -> object: logger.info("runner: calling the model", extra={"correlation_id": current_correlation_id()}) try: # Tracing off: the SDK's default trace exporter posts to OpenAI, not the proxy. - return await Runner.run( - self._agent, text, run_config=RunConfig(tracing_disabled=True) - ) + return await Runner.run(self._agent, text, run_config=RunConfig(tracing_disabled=True)) except Exception as exc: typed = _refusal(exc) if typed is exc: diff --git a/python/scripts/verify_frameworks.py b/python/scripts/verify_frameworks.py index c40e513c..83f07941 100644 --- a/python/scripts/verify_frameworks.py +++ b/python/scripts/verify_frameworks.py @@ -68,32 +68,82 @@ class path is wrong -> ImportError/AttributeError. If a kwarg name is wrong # in the nightly matrix (BG §1.8, #197); the other seven are supported at # connection_kwargs() only. `--only ` targets one framework. FRAMEWORKS: list[tuple[str, str, str, str, str]] = [ - ("langgraph", "donkey_kit.integrations.langgraph", "chat_model", - "langchain_openai.ChatOpenAI", "langchain-openai"), - ("adk", "donkey_kit.integrations.adk", "model", - "google.adk.models.lite_llm.LiteLlm", "google-adk"), + ( + "langgraph", + "donkey_kit.integrations.langgraph", + "chat_model", + "langchain_openai.ChatOpenAI", + "langchain-openai", + ), + ( + "adk", + "donkey_kit.integrations.adk", + "model", + "google.adk.models.lite_llm.LiteLlm", + "google-adk", + ), # ADK's native Gemini on a Format=Gemini proxy (#691). A dotted key is a second # factory of the same framework: `--only adk` selects both, and its extra is # the part before the dot. - ("adk.gemini", "donkey_kit.integrations.adk", "gemini", - "google.adk.models.Gemini", "google-adk"), - ("strands", "donkey_kit.integrations.strands", "model", - "strands.models.openai.OpenAIModel", "strands-agents"), - ("agent_framework", "donkey_kit.integrations.agent_framework", "chat_client", - "agent_framework.openai.OpenAIChatClient", "agent-framework"), + ( + "adk.gemini", + "donkey_kit.integrations.adk", + "gemini", + "google.adk.models.Gemini", + "google-adk", + ), + ( + "strands", + "donkey_kit.integrations.strands", + "model", + "strands.models.openai.OpenAIModel", + "strands-agents", + ), + ( + "agent_framework", + "donkey_kit.integrations.agent_framework", + "chat_client", + "agent_framework.openai.OpenAIChatClient", + "agent-framework", + ), # The same factory with api="chat_completions" (#826). - ("agent_framework.chat_completions", "donkey_kit.integrations.agent_framework", - "chat_client", "agent_framework.openai.OpenAIChatCompletionClient", "agent-framework"), - ("openai_agents", "donkey_kit.integrations.openai_agents", "model", - "agents.OpenAIChatCompletionsModel", "openai-agents"), - ("anthropic", "donkey_kit.integrations.anthropic", "client", - "anthropic.AsyncAnthropic", "anthropic"), + ( + "agent_framework.chat_completions", + "donkey_kit.integrations.agent_framework", + "chat_client", + "agent_framework.openai.OpenAIChatCompletionClient", + "agent-framework", + ), + ( + "openai_agents", + "donkey_kit.integrations.openai_agents", + "model", + "agents.OpenAIChatCompletionsModel", + "openai-agents", + ), + ( + "anthropic", + "donkey_kit.integrations.anthropic", + "client", + "anthropic.AsyncAnthropic", + "anthropic", + ), # crewai.LLM is a factory: openai/ + base_url returns this native provider. # Pin the concrete class — every other provider is a crewai.BaseLLM too. - ("crewai", "donkey_kit.integrations.crewai", "llm", - "crewai.llms.providers.openai.completion.OpenAICompletion", "crewai"), - ("llamaindex", "donkey_kit.integrations.llamaindex", "llm", - "llama_index.llms.openai_like.OpenAILike", "llama-index-llms-openai-like"), + ( + "crewai", + "donkey_kit.integrations.crewai", + "llm", + "crewai.llms.providers.openai.completion.OpenAICompletion", + "crewai", + ), + ( + "llamaindex", + "donkey_kit.integrations.llamaindex", + "llm", + "llama_index.llms.openai_like.OpenAILike", + "llama-index-llms-openai-like", + ), ] PROXY_ENV = ( @@ -311,9 +361,7 @@ async def run(only: list[str] | None, live: bool) -> list[Result]: obj = check_signature(res, import_path, factory, distribution) if live and obj is not None and res.class_matches: if not have_real: - res.live = ( - "skipped: set the 3 DONKEY_LLM_PROXY_* env vars for a live round-trip" - ) + res.live = "skipped: set the 3 DONKEY_LLM_PROXY_* env vars for a live round-trip" else: await check_live(res, obj) results.append(res) @@ -371,7 +419,8 @@ def main() -> int: ap.add_argument("--only", nargs="+", metavar="FW", help="restrict to these framework keys") ap.add_argument("--json", action="store_true", help="emit JSON instead of a table") ap.add_argument( - "--require-installed", action="store_true", + "--require-installed", + action="store_true", help=( "fail if a framework named by --only is NOT INSTALLED, instead of " "exiting 0 (a CI leg that installed a specific extra wants this; see " @@ -379,7 +428,8 @@ def main() -> int: ), ) ap.add_argument( - "--emit-verified", action="store_true", + "--emit-verified", + action="store_true", help="print docs/verified-apis.md §8 markdown rows for confirmed frameworks", ) args = ap.parse_args() @@ -399,12 +449,16 @@ def main() -> int: # Fail CI only when an INSTALLED framework fails its signature check. failed = [ - r for r in results + r + for r in results if r.installed and not r.blocked and not (r.signature_ok and r.class_matches) ] if failed: - print(f"\n{len(failed)} installed framework(s) failed signature verification: " - f"{', '.join(r.framework for r in failed)}", file=sys.stderr) + print( + f"\n{len(failed)} installed framework(s) failed signature verification: " + f"{', '.join(r.framework for r in failed)}", + file=sys.stderr, + ) return 1 # #748: a CI leg naming a framework via --only expects it to actually be diff --git a/python/src/donkey_kit/cli/_app.py b/python/src/donkey_kit/cli/_app.py index b091319d..375f0e99 100644 --- a/python/src/donkey_kit/cli/_app.py +++ b/python/src/donkey_kit/cli/_app.py @@ -14,8 +14,7 @@ # A curated ImportError, not SystemExit: importing this module must not # kill the interpreter (test collection, docs tooling), #811. raise ImportError( - 'The donkey CLI needs the [cli] extra. Install it with:\n' - ' pip install "donkey-kit[cli]"' + 'The donkey CLI needs the [cli] extra. Install it with:\n pip install "donkey-kit[cli]"' ) from exc from ..core.config import TOML_NAME diff --git a/python/src/donkey_kit/cli/doctor.py b/python/src/donkey_kit/cli/doctor.py index 4e736644..f19229ef 100644 --- a/python/src/donkey_kit/cli/doctor.py +++ b/python/src/donkey_kit/cli/doctor.py @@ -270,8 +270,11 @@ def _probe_checks(result: ProbeResult) -> list[DoctorCheck]: where = err.base_url or "the configured URL" gateway = DoctorCheck("gateway", Level.FAIL, f"unreachable — {where}", err.remediation) not_checked = "not checked — gateway unreachable" - return [gateway, DoctorCheck("credentials", Level.SKIP, not_checked), - DoctorCheck("model", Level.SKIP, not_checked)] + return [ + gateway, + DoctorCheck("credentials", Level.SKIP, not_checked), + DoctorCheck("model", Level.SKIP, not_checked), + ] if isinstance(err, AuthError): creds = DoctorCheck("credentials", Level.FAIL, "rejected by the gateway", err.remediation) @@ -285,34 +288,44 @@ def _probe_checks(result: ProbeResult) -> list[DoctorCheck]: if isinstance(err, UpstreamRequestError) and ( err.code == "model_not_found" or err.param == "model" ): - model = DoctorCheck("model", Level.FAIL, str(err).split(": ", 1)[-1] or "rejected", - _model_remediation(err)) + model = DoctorCheck( + "model", Level.FAIL, str(err).split(": ", 1)[-1] or "rejected", _model_remediation(err) + ) return [gateway, creds, model] if _unserved_base_path(err): - gateway = DoctorCheck("gateway", Level.FAIL, - "reachable, but no proxy on this base path (empty 404)", - _UNSERVED_REMEDIATION) + gateway = DoctorCheck( + "gateway", + Level.FAIL, + "reachable, but no proxy on this base path (empty 404)", + _UNSERVED_REMEDIATION, + ) not_checked = "not checked — no proxy on this base path" - return [gateway, DoctorCheck("credentials", Level.SKIP, not_checked), - DoctorCheck("model", Level.SKIP, not_checked)] + return [ + gateway, + DoctorCheck("credentials", Level.SKIP, not_checked), + DoctorCheck("model", Level.SKIP, not_checked), + ] not_served = _route_not_served(err) if not_served is not None: provider = not_served.headers.get(LLM_PROVIDER_HEADER) or "upstream" responses = DoctorCheck( - "responses", Level.INFO, + "responses", + Level.INFO, f"/responses not served on this route (404 from the {provider} upstream)", _RESPONSES_REMEDIATION, ) chat = result.chat if chat is None: - model = DoctorCheck("model", Level.SKIP, - "not checked — /responses not served on this route") + model = DoctorCheck( + "model", Level.SKIP, "not checked — /responses not served on this route" + ) return [gateway, creds, model, responses] if _route_not_served(chat.error) is not None: model = DoctorCheck( - "model", Level.FAIL, + "model", + Level.FAIL, "routed, but the upstream serves neither /responses nor /chat/completions", _RESPONSES_REMEDIATION, ) @@ -328,8 +341,7 @@ def _probe_checks(result: ProbeResult) -> list[DoctorCheck]: # line so doctor stays honest about what it saw. if err is not None: detail = getattr(err, "remediation", None) - return [gateway, creds, model, - DoctorCheck("policy", Level.INFO, str(err), detail)] + return [gateway, creds, model, DoctorCheck("policy", Level.INFO, str(err), detail)] return [gateway, creds, model] @@ -348,8 +360,9 @@ def _budget_check(budget: Budget | None) -> DoctorCheck: reading is. Never implies live data (AC3): the proxy has no budget endpoint, so ``observed_at`` is the only truth about freshness.""" if budget is None or budget.observed_at is None: - return DoctorCheck("budget", Level.INFO, - "not yet observed — no call has returned a budget window") + return DoctorCheck( + "budget", Level.INFO, "not yet observed — no call has returned a budget window" + ) ago = _humanize((_utcnow() - budget.observed_at).total_seconds()) remaining = f"{budget.remaining:,}" if budget.remaining is not None else "?" limit = f"{budget.limit:,}" if budget.limit is not None else "?" @@ -492,7 +505,7 @@ def doctor( checks = run_diagnostics(model, path=config_path) if config_path else run_diagnostics(model) except ImportError as exc: # openai (the [llm] extra) not installed typer.secho( - 'donkey doctor needs the [llm] extra for the probe client. Install it with:\n' + "donkey doctor needs the [llm] extra for the probe client. Install it with:\n" ' pip install "donkey-kit[llm]"', fg="yellow", err=True, @@ -508,8 +521,12 @@ def doctor( typer.echo( json.dumps( [ - {"name": c.name, "level": c.level.value, "detail": c.detail, - "remediation": c.remediation} + { + "name": c.name, + "level": c.level.value, + "detail": c.detail, + "remediation": c.remediation, + } for c in checks ], indent=2, diff --git a/python/src/donkey_kit/cli/init.py b/python/src/donkey_kit/cli/init.py index 4fac1d7c..b9abec16 100644 --- a/python/src/donkey_kit/cli/init.py +++ b/python/src/donkey_kit/cli/init.py @@ -53,11 +53,7 @@ def _collect_missing(config: DonkeyConfig) -> list[str]: reusing ``DonkeyConfig.missing_fields`` (what ``validated`` reports) as the single source of truth for what is required (config resolution), rather than duplicating the field set here.""" - return [ - item - for need in ("control_plane", "llm") - for item in config.missing_fields(need=need) - ] + return [item for need in ("control_plane", "llm") for item in config.missing_fields(need=need)] def _render_toml(config: DonkeyConfig, missing: list[str]) -> str: diff --git a/python/src/donkey_kit/cli/mock.py b/python/src/donkey_kit/cli/mock.py index 9d9fe222..08be0b4d 100644 --- a/python/src/donkey_kit/cli/mock.py +++ b/python/src/donkey_kit/cli/mock.py @@ -61,7 +61,7 @@ def mock( server.serve(host=host, port=port, config=config) except ImportError as exc: typer.secho( - 'The local gateway simulator needs the [local] extra. Install it with:\n' + "The local gateway simulator needs the [local] extra. Install it with:\n" ' pip install "donkey-kit[local]"', fg="yellow", err=True, diff --git a/python/src/donkey_kit/cli/test.py b/python/src/donkey_kit/cli/test.py index f599dcf1..c5eb6477 100644 --- a/python/src/donkey_kit/cli/test.py +++ b/python/src/donkey_kit/cli/test.py @@ -35,7 +35,7 @@ def test( """ if importlib.util.find_spec("pytest") is None: typer.secho( - 'donkey test needs the [test] extra. Install it with:\n' + "donkey test needs the [test] extra. Install it with:\n" ' pip install "donkey-kit[test]"', fg="yellow", err=True, diff --git a/python/src/donkey_kit/conformance/gateway.py b/python/src/donkey_kit/conformance/gateway.py index 2c2f60d0..dc5ba143 100644 --- a/python/src/donkey_kit/conformance/gateway.py +++ b/python/src/donkey_kit/conformance/gateway.py @@ -118,9 +118,7 @@ class Gateway: tested against, and every response carries ``x-donkey-simulator: true`` (#189). """ - def __init__( - self, *, url: str, recorder: _RecordingApp, shutdown: Callable[[], None] - ) -> None: + def __init__(self, *, url: str, recorder: _RecordingApp, shutdown: Callable[[], None]) -> None: self._url = url self._recorder = recorder self._shutdown = shutdown @@ -148,9 +146,7 @@ def set_scenarios(self, *scenarios: str | FaultScenario) -> None: string (e.g. ``"pii_block:every=1"``). Scenarios are stateful and single-use, so this builds a fresh simulator each call; the request log is left untouched (use :meth:`reset` to clear it).""" - parsed = tuple( - parse_scenario(s) if isinstance(s, str) else s for s in scenarios - ) + parsed = tuple(parse_scenario(s) if isinstance(s, str) else s for s in scenarios) self._recorder.app = build_app(SimulatorConfig(scenarios=parsed)) def reset(self) -> None: @@ -207,9 +203,7 @@ def start_gateway( if not server.started: server.should_exit = True thread.join(timeout=5.0) - raise RuntimeError( - f"local gateway simulator did not start within {startup_timeout}s" - ) + raise RuntimeError(f"local gateway simulator did not start within {startup_timeout}s") port = _bound_port(server) diff --git a/python/src/donkey_kit/conformance/harness.py b/python/src/donkey_kit/conformance/harness.py index 9757800f..c3830d42 100644 --- a/python/src/donkey_kit/conformance/harness.py +++ b/python/src/donkey_kit/conformance/harness.py @@ -297,9 +297,7 @@ def _render_records(records: list[logging.LogRecord]) -> str: except Exception: # noqa: BLE001 — a bad format string must not abort capture parts.append(str(record.msg)) parts.extend( - str(value) - for key, value in record.__dict__.items() - if key not in _STD_LOGRECORD_KEYS + str(value) for key, value in record.__dict__.items() if key not in _STD_LOGRECORD_KEYS ) return "\n".join(parts) @@ -465,9 +463,7 @@ async def run_conformance( results: list[Result] = [] for scenario in SCENARIOS: if scenario.name in known: - results.append( - Result(scenario.name, scenario.title, "exempt", known[scenario.name]) - ) + results.append(Result(scenario.name, scenario.title, "exempt", known[scenario.name])) continue harness = ConformanceHarness(agent_factory, run_input=run_input) outcome = await harness.run_scenario(scenario) diff --git a/python/src/donkey_kit/conformance/plugin.py b/python/src/donkey_kit/conformance/plugin.py index 73ed2a08..e53d8cc5 100644 --- a/python/src/donkey_kit/conformance/plugin.py +++ b/python/src/donkey_kit/conformance/plugin.py @@ -115,8 +115,11 @@ def _registered_options(parser: pytest.Parser) -> set[str]: time — too late to catch — hence the scan before adding the alias.""" groups = [*getattr(parser, "_groups", ()), getattr(parser, "_anonymous", None)] return { - name for group in groups if group is not None - for option in group.options for name in option.names() + name + for group in groups + if group is not None + for option in group.options + for name in option.names() } @@ -125,14 +128,10 @@ def _import_target(spec: str, *, what: str) -> tuple[Any, Any]: (a clean CLI error, no traceback) for a malformed spec, an unimportable module, or a missing attribute.""" if ":" not in spec: - raise pytest.UsageError( - f"{what} must be given as 'module:attribute', got {spec!r}" - ) + raise pytest.UsageError(f"{what} must be given as 'module:attribute', got {spec!r}") mod_name, _, attr = spec.partition(":") if not mod_name or not attr: - raise pytest.UsageError( - f"{what} must be given as 'module:attribute', got {spec!r}" - ) + raise pytest.UsageError(f"{what} must be given as 'module:attribute', got {spec!r}") try: module = importlib.import_module(mod_name) except ImportError as exc: diff --git a/python/src/donkey_kit/conformance/suite.py b/python/src/donkey_kit/conformance/suite.py index 89523e5b..af2f764f 100644 --- a/python/src/donkey_kit/conformance/suite.py +++ b/python/src/donkey_kit/conformance/suite.py @@ -158,9 +158,7 @@ def _no_model_call(obs: Observation) -> Outcome | None: if obs.wire_sends: return None blocked = ( - f" ({obs.bypass_attempts} request(s) blocked by the harness)" - if obs.bypass_attempts - else "" + f" ({obs.bypass_attempts} request(s) blocked by the harness)" if obs.bypass_attempts else "" ) if obs.unobservable_adapters: # The adapter built the client, so "use the donkey" is not the fix: say @@ -206,16 +204,13 @@ async def _swallows_pii_as_generic(ctx: ScenarioContext) -> Outcome: if (no_call := _no_model_call(obs)) is not None: return no_call if obs.raised is None: - return Outcome( - True, "handled the PII refusal without leaking a generic exception" - ) + return Outcome(True, "handled the PII refusal without leaking a generic exception") if isinstance(obs.raised, PIIDetected): return Outcome(True, "surfaced the refusal as a typed PIIDetected") if isinstance(obs.raised, DonkeyError): return Outcome( False, - f"raised {type(obs.raised).__name__}, not PIIDetected — the refusal " - f"was misclassified", + f"raised {type(obs.raised).__name__}, not PIIDetected — the refusal was misclassified", ) return Outcome( False, @@ -244,9 +239,7 @@ async def _works_without_budget_headers(ctx: ScenarioContext) -> Outcome: if (no_call := _no_model_call(obs)) is not None: return no_call if obs.raised is None: - return Outcome( - True, "completed a run when the gateway returned no budget headers" - ) + return Outcome(True, "completed a run when the gateway returned no budget headers") return Outcome( False, f"raised {type(obs.raised).__name__} when budget headers were absent — " @@ -277,8 +270,7 @@ async def _works_without_budget_headers(ctx: ScenarioContext) -> Outcome: Scenario( name="works_without_budget_headers", title="Works without budget headers", - question="Does your agent still work when the gateway sends no " - "budget headers?", + question="Does your agent still work when the gateway sends no budget headers?", check=_works_without_budget_headers, ), ) diff --git a/python/src/donkey_kit/core/__init__.py b/python/src/donkey_kit/core/__init__.py index a1466555..a91cc39e 100644 --- a/python/src/donkey_kit/core/__init__.py +++ b/python/src/donkey_kit/core/__init__.py @@ -3,6 +3,7 @@ HARD RULE (the layered architecture): nothing in this package may import from ``donkey_kit.integrations``. Enforced by import-linter in CI. """ + from __future__ import annotations from .auth import AnypointConnectedApp, AuthProvider, ChainedAuth, StaticToken diff --git a/python/src/donkey_kit/core/config.py b/python/src/donkey_kit/core/config.py index b85895cc..e0427db0 100644 --- a/python/src/donkey_kit/core/config.py +++ b/python/src/donkey_kit/core/config.py @@ -287,7 +287,7 @@ class DonkeyConfig: org_id: str | None = None environment: str = "Sandbox" region: Region = "us" - base_url: str | None = None # override; else derived from region + base_url: str | None = None # override; else derived from region # --- LLM proxy (data plane) — SEPARATE credential from the control plane --- # Auth is a client_id/client_secret REQUEST-header pair (client-id-enforcement), @@ -380,9 +380,7 @@ class DonkeyConfig: # Where each field was resolved from and a keyed digest of the value it had # there, filled in by resolve(). A field with no entry, or whose value no # longer matches, was set in code and counts as explicit. - _sources: Mapping[str, _Loaded] = field( - default_factory=dict, repr=False, compare=False - ) + _sources: Mapping[str, _Loaded] = field(default_factory=dict, repr=False, compare=False) def __post_init__(self) -> None: loaded = {name: _as_loaded(name, entry) for name, entry in self._sources.items()} @@ -711,9 +709,7 @@ def _binding_error( if offer_local_file: local = origin.path.parent / LOCAL_TOML_NAME options.append(f"keep the credentials in {local}, next to the project file") - options.append( - f"trust the project config files by setting {TRUST_PROJECT_CONFIG_ENV}=1" - ) + options.append(f"trust the project config files by setting {TRUST_PROJECT_CONFIG_ENV}=1") return ConfigError( f"Not sending {', '.join(credentials)} to {host_of(url)}: {key} is set in " f"{origin.path}, and credentials from outside the project config files " diff --git a/python/src/donkey_kit/core/endpoints.py b/python/src/donkey_kit/core/endpoints.py index 04764597..337723bb 100644 --- a/python/src/donkey_kit/core/endpoints.py +++ b/python/src/donkey_kit/core/endpoints.py @@ -98,8 +98,7 @@ def require_secure_url(url: str, *, name: str) -> None: remedy = "Change it to the https:// address of the service" if scheme == "http" and parts is not None and parts.hostname: remedy += ( - f", or set {ALLOW_HTTP_ENV}=1 in the environment to allow plain http:// " - "to other hosts" + f", or set {ALLOW_HTTP_ENV}=1 in the environment to allow plain http:// to other hosts" ) raise ConfigError( f"{name} must be an https:// URL (got {scheme} scheme, {host}). Plain http:// " diff --git a/python/src/donkey_kit/core/errors.py b/python/src/donkey_kit/core/errors.py index b287a790..c9570cb7 100644 --- a/python/src/donkey_kit/core/errors.py +++ b/python/src/donkey_kit/core/errors.py @@ -807,9 +807,7 @@ def _match_agent_killed(s: _Shape) -> DonkeyError | None: if s.error_obj is None or s.error_obj.get("code") != "agent_killed": return None message = str_or_none(s.error_obj.get("message")) - return AgentKilled( - message or f"Agent blocked by an active kill switch ({s.status}).", **s.kw - ) + return AgentKilled(message or f"Agent blocked by an active kill switch ({s.status}).", **s.kw) def _match_content_safety(s: _Shape) -> DonkeyError | None: @@ -1014,14 +1012,8 @@ def _sent_ids(response: ResponseLike) -> tuple[str | None, str | None]: except RuntimeError: return None, None headers = request.headers - corr_name = ( - request.extensions.get("donkey_correlation_header") - or _verify.CORRELATION_ID_HEADER - ) - call_name = ( - request.extensions.get("donkey_call_id_header") - or _verify.CALL_ID_HEADER - ) + corr_name = request.extensions.get("donkey_correlation_header") or _verify.CORRELATION_ID_HEADER + call_name = request.extensions.get("donkey_call_id_header") or _verify.CALL_ID_HEADER return headers.get(corr_name), headers.get(call_name) diff --git a/python/src/donkey_kit/core/refusals.py b/python/src/donkey_kit/core/refusals.py index 8b843f52..2bfb168d 100644 --- a/python/src/donkey_kit/core/refusals.py +++ b/python/src/donkey_kit/core/refusals.py @@ -91,9 +91,7 @@ def _is_group(exc: BaseException) -> bool: return isinstance(exc, _group_types()) -def translate( - exc: BaseException, translators: Iterable[Translator] = () -) -> DonkeyError | None: +def translate(exc: BaseException, translators: Iterable[Translator] = ()) -> DonkeyError | None: """The typed error ``exc`` stands for, or ``None`` when it is not a refusal. Returns ``exc`` itself when it already is a :class:`DonkeyError`. Otherwise diff --git a/python/src/donkey_kit/core/transport/headers.py b/python/src/donkey_kit/core/transport/headers.py index 246e5895..c3e53357 100644 --- a/python/src/donkey_kit/core/transport/headers.py +++ b/python/src/donkey_kit/core/transport/headers.py @@ -76,9 +76,7 @@ def cost_headers(cfg: DonkeyConfig, tags: CostTags) -> dict[str, str]: sends these only when ``DonkeyConfig.send_cost_headers`` is enabled; this builder itself does not check the flag. Values are pre-validated by :class:`CostTags`, so they are always header-safe.""" - override = { - field: getattr(cfg, attr) for field, attr, _default in _COST_HEADER_SOURCES - } + override = {field: getattr(cfg, attr) for field, attr, _default in _COST_HEADER_SOURCES} default = {field: name for field, _attr, name in _COST_HEADER_SOURCES} headers: dict[str, str] = {} for field, value in tags.items(): diff --git a/python/src/donkey_kit/core/transport/observe.py b/python/src/donkey_kit/core/transport/observe.py index a879d25a..1f318193 100644 --- a/python/src/donkey_kit/core/transport/observe.py +++ b/python/src/donkey_kit/core/transport/observe.py @@ -31,9 +31,7 @@ _log = logging.getLogger(__name__) -def _observe_final( - request: httpx.Request, response: httpx.Response, budget: Budget | None -) -> None: +def _observe_final(request: httpx.Request, response: httpx.Response, budget: Budget | None) -> None: """Feed the final response to the attached :class:`Budget` from its ``x-token-*`` headers (#185), and record a governed model call's gateway identity into ``donkey.last_call`` (#362). Both are no-ops when not diff --git a/python/src/donkey_kit/donkey.py b/python/src/donkey_kit/donkey.py index ab16d525..39bb0497 100644 --- a/python/src/donkey_kit/donkey.py +++ b/python/src/donkey_kit/donkey.py @@ -635,9 +635,7 @@ async def lookup_crm(customer_id: str) -> dict: ... """ return register_tool(func) - def simulate( - self, error: type[DonkeyError], *, times: int = 1 - ) -> AbstractContextManager[None]: + def simulate(self, error: type[DonkeyError], *, times: int = 1) -> AbstractContextManager[None]: """Inject a real gateway refusal in-process, no server (#190, BG §1.5). Swaps a fixture-returning transport onto this Donkey's data-plane HTTP client(s) for diff --git a/python/src/donkey_kit/integrations/__init__.py b/python/src/donkey_kit/integrations/__init__.py index 73b16e6c..d06e98de 100644 --- a/python/src/donkey_kit/integrations/__init__.py +++ b/python/src/donkey_kit/integrations/__init__.py @@ -196,37 +196,69 @@ def missing_framework_error(extra: str, missing: str | None = None) -> ImportErr # not a removal. ADAPTERS: dict[str, AdapterSpec] = { "langgraph": AdapterSpec( - "langgraph", ".langgraph", "LangGraphAdapter", "langgraph", - conformance_tested=True, probe=("langchain_openai",), + "langgraph", + ".langgraph", + "LangGraphAdapter", + "langgraph", + conformance_tested=True, + probe=("langchain_openai",), ), "adk": AdapterSpec( - "adk", ".adk", "ADKAdapter", "adk", - conformance_tested=False, probe=("google.adk", "litellm"), + "adk", + ".adk", + "ADKAdapter", + "adk", + conformance_tested=False, + probe=("google.adk", "litellm"), ), "strands": AdapterSpec( - "strands", ".strands", "StrandsAdapter", "strands", - conformance_tested=False, probe=("strands", "openai"), + "strands", + ".strands", + "StrandsAdapter", + "strands", + conformance_tested=False, + probe=("strands", "openai"), refusal_translator="refusal_translator", ), "agent_framework": AdapterSpec( - "agent_framework", ".agent_framework", "AgentFrameworkAdapter", "agent_framework", - conformance_tested=False, probe=("agent_framework",), + "agent_framework", + ".agent_framework", + "AgentFrameworkAdapter", + "agent_framework", + conformance_tested=False, + probe=("agent_framework",), refusal_translator="refusal_translator", ), "openai_agents": AdapterSpec( - "openai_agents", ".openai_agents", "OpenAIAgentsAdapter", "openai-agents", - conformance_tested=False, probe=("agents",), + "openai_agents", + ".openai_agents", + "OpenAIAgentsAdapter", + "openai-agents", + conformance_tested=False, + probe=("agents",), ), "anthropic": AdapterSpec( - "anthropic", ".anthropic", "AnthropicAdapter", "anthropic", - conformance_tested=False, probe=("anthropic",), + "anthropic", + ".anthropic", + "AnthropicAdapter", + "anthropic", + conformance_tested=False, + probe=("anthropic",), ), "crewai": AdapterSpec( - "crewai", ".crewai", "CrewAIAdapter", "crewai", - conformance_tested=False, probe=("crewai",), + "crewai", + ".crewai", + "CrewAIAdapter", + "crewai", + conformance_tested=False, + probe=("crewai",), ), "llamaindex": AdapterSpec( - "llamaindex", ".llamaindex", "LlamaIndexAdapter", "llamaindex", - conformance_tested=False, probe=("llama_index.llms.openai_like",), + "llamaindex", + ".llamaindex", + "LlamaIndexAdapter", + "llamaindex", + conformance_tested=False, + probe=("llama_index.llms.openai_like",), ), } diff --git a/python/src/donkey_kit/integrations/_base.py b/python/src/donkey_kit/integrations/_base.py index b1d07af3..6c9d5e93 100644 --- a/python/src/donkey_kit/integrations/_base.py +++ b/python/src/donkey_kit/integrations/_base.py @@ -139,9 +139,7 @@ def typed_refusals() -> TypedRefusals: def _own_sync_client(self) -> DonkeyClient: if self._owned_sync is None: - self._owned_sync = build_sync_http_client( - self._cfg, origins=self._http.checked_origins - ) + self._owned_sync = build_sync_http_client(self._cfg, origins=self._http.checked_origins) return self._owned_sync def close(self) -> None: diff --git a/python/src/donkey_kit/llm/__init__.py b/python/src/donkey_kit/llm/__init__.py index 2571ca65..68a83849 100644 --- a/python/src/donkey_kit/llm/__init__.py +++ b/python/src/donkey_kit/llm/__init__.py @@ -1,4 +1,5 @@ """llm/ — governed model access, framework-free surface (BG §1.8, BG §1.1).""" + from __future__ import annotations from .catalog import ModelCapabilities, ModelHandle, heuristic_capabilities diff --git a/python/src/donkey_kit/llm/client.py b/python/src/donkey_kit/llm/client.py index 5ea743f1..07f676be 100644 --- a/python/src/donkey_kit/llm/client.py +++ b/python/src/donkey_kit/llm/client.py @@ -98,9 +98,7 @@ def __init__( def _own_sync_client(self) -> DonkeyClient: if self._owned_sync is None: - self._owned_sync = build_sync_http_client( - self._cfg, origins=self._http.checked_origins - ) + self._owned_sync = build_sync_http_client(self._cfg, origins=self._http.checked_origins) return self._owned_sync def close(self) -> None: diff --git a/python/src/donkey_kit/registry/__init__.py b/python/src/donkey_kit/registry/__init__.py index c9457bc0..6f08a61b 100644 --- a/python/src/donkey_kit/registry/__init__.py +++ b/python/src/donkey_kit/registry/__init__.py @@ -1,4 +1,5 @@ """registry/ — Exchange discovery + governed-only predicate (BG §2.7).""" + from __future__ import annotations from .criteria import STRICT, Check, GovernanceCriteria, GovernanceReport, evaluate diff --git a/python/src/donkey_kit/registry/introspect.py b/python/src/donkey_kit/registry/introspect.py index d0a76121..2c4a27a3 100644 --- a/python/src/donkey_kit/registry/introspect.py +++ b/python/src/donkey_kit/registry/introspect.py @@ -30,9 +30,7 @@ DerivationMode = str # "auto" | "auto:live" | "auto:static" | "auto:check" -async def derive_descriptor( - entrypoint: str, *, mode: DerivationMode = "auto" -) -> dict[str, object]: +async def derive_descriptor(entrypoint: str, *, mode: DerivationMode = "auto") -> dict[str, object]: """Derive an asset descriptor from code. Blocked until the per-framework attribute contracts are verified (BG §2.5 / docs/verified-apis.md §10).""" raise _verify.blocked( diff --git a/python/src/donkey_kit/registry/publication.py b/python/src/donkey_kit/registry/publication.py index 46954ae9..5868891c 100644 --- a/python/src/donkey_kit/registry/publication.py +++ b/python/src/donkey_kit/registry/publication.py @@ -54,7 +54,7 @@ class PublicationAssetType(Enum): class VersionStrategy(Enum): """How a :class:`Publication` picks its version; ``PINNED`` (the default) never bumps.""" - PINNED = "pinned" # default — no implicit bumps in a shared catalog + PINNED = "pinned" # default — no implicit bumps in a shared catalog FROM_PACKAGE = "from-package" SEMANTIC_AUTO = "semantic-auto" @@ -162,9 +162,7 @@ def check_description_quality( DescriptionIssue(name, "tautological", f"description equals identifier {name!r}") ) elif len(desc.strip()) < min_len: - issues.append( - DescriptionIssue(name, "too-short", f"description under {min_len} chars") - ) + issues.append(DescriptionIssue(name, "too-short", f"description under {min_len} chars")) return issues diff --git a/python/src/donkey_kit/simulator/app.py b/python/src/donkey_kit/simulator/app.py index 0c848858..e5803e75 100644 --- a/python/src/donkey_kit/simulator/app.py +++ b/python/src/donkey_kit/simulator/app.py @@ -97,6 +97,7 @@ # Every shape the model-id sentinel can force to a specific captured fixture. _SENTINEL_SHAPES = _REJECTION_SHAPES | _HAPPY_SHAPES + @dataclass(frozen=True) class _Route: """The happy-path fixture shapes one model-call route serves.""" @@ -263,9 +264,7 @@ async def _model_call(self, request: Any, route: _Route) -> Any: window_headers.update(await self._synth_ratelimit_header()) return self._happy(payload, route, window_headers) - def _happy( - self, payload: Any, route: _Route, ratelimit_header: dict[str, str] - ) -> Any: + def _happy(self, payload: Any, route: _Route, ratelimit_header: dict[str, str]) -> Any: """Serve the route's happy-path 200 (or its stream), carrying the given window headers (the token prose header, and the request trio when a request-limit scenario is on).""" diff --git a/python/src/donkey_kit/simulator/fixtures.py b/python/src/donkey_kit/simulator/fixtures.py index 712e657e..8c4c2961 100644 --- a/python/src/donkey_kit/simulator/fixtures.py +++ b/python/src/donkey_kit/simulator/fixtures.py @@ -79,10 +79,10 @@ def render_ratelimit_prose(remaining: int, limit: int, reset_ms: int) -> str: scenario, #188) is what stops the two renderers from drifting apart. """ return ( - f"Token rate limit: {remaining} tokens remaining of " - f"{limit} limit. Reset in {reset_ms}ms." + f"Token rate limit: {remaining} tokens remaining of {limit} limit. Reset in {reset_ms}ms." ) + # Header replay is an allow-list, not a deny-list: replay only the semantic and # discriminator headers a client (and classify()) actually consume, and let the # consumer generate framing (content-length, transfer-encoding, connection). @@ -205,9 +205,7 @@ class _Spec: "reject.content-safety.body.json", 403, ), - "content-moderation": _Spec( - "rejections", "reject.content-moderation.headers.txt", None, 400 - ), + "content-moderation": _Spec("rejections", "reject.content-moderation.headers.txt", None, 400), # Agent Kill Switch (docs/verified-apis.md §4, #694): nested error.code == "agent_killed". "agent-killed": _Spec( "rejections", @@ -265,9 +263,7 @@ class _Spec: "responses.stream.sample.sse", 200, ), - "models-notfound": _Spec( - "anypoint/llm_proxy", "models.notfound.headers.txt", None, 404 - ), + "models-notfound": _Spec("anypoint/llm_proxy", "models.notfound.headers.txt", None, 404), # --- the /chat/completions happy path (#895) --- # The non-streaming 200 is a live capture (docs/verified-apis.md §2, Chat # Completions row): the Azure OpenAI model-based route, #896. @@ -415,9 +411,8 @@ def compute_manifest() -> dict[str, str]: """A ``{"/": "sha256:"}`` map over every fixture file the simulator serves, computed from the bytes on disk right now.""" return { - f"{directory}/{name}": "sha256:" + hashlib.sha256( - fixture_bytes(directory, name) - ).hexdigest() + f"{directory}/{name}": "sha256:" + + hashlib.sha256(fixture_bytes(directory, name)).hexdigest() for directory, name in _served_files() } diff --git a/python/src/donkey_kit/simulator/scenarios.py b/python/src/donkey_kit/simulator/scenarios.py index ad73c750..3cdcba5f 100644 --- a/python/src/donkey_kit/simulator/scenarios.py +++ b/python/src/donkey_kit/simulator/scenarios.py @@ -268,9 +268,7 @@ def happy_path_headers(self) -> dict[str, str]: """The prose ``x-llm-proxy-ratelimit`` header for a passing call, rendered from the live counter. Call only after :meth:`on_call` returned ``None``.""" return { - RATELIMIT_HEADER: render_ratelimit_prose( - self._remaining, self._limit, self._reset_ms - ) + RATELIMIT_HEADER: render_ratelimit_prose(self._remaining, self._limit, self._reset_ms) } @@ -389,9 +387,7 @@ def parse_scenario(spec: str) -> FaultScenario: name, sep, rest = spec.partition(":") name = name.strip() if not sep: - raise ScenarioError( - f"scenario {spec!r} has no ':' — expected ':'" - ) + raise ScenarioError(f"scenario {spec!r} has no ':' — expected ':'") if name == "injection": params = _parse_params(rest) pattern = params.get("on-pattern") diff --git a/python/src/donkey_kit/tools/__init__.py b/python/src/donkey_kit/tools/__init__.py index b8ac2f9a..36c675e1 100644 --- a/python/src/donkey_kit/tools/__init__.py +++ b/python/src/donkey_kit/tools/__init__.py @@ -1,4 +1,5 @@ """tools/ — governed tool access: MCP session mgmt + tool filtering (BG §2.7).""" + from __future__ import annotations from .filter import ToolDescriptor, ToolFilter, resolve_collisions diff --git a/python/src/donkey_kit/tools/session.py b/python/src/donkey_kit/tools/session.py index e2c1393b..d0a4f41f 100644 --- a/python/src/donkey_kit/tools/session.py +++ b/python/src/donkey_kit/tools/session.py @@ -99,8 +99,7 @@ def langgraph(self) -> list[object]: # -> list[BaseTool] NotImplementedError: Always, for now; see docs/verified-apis.md §9. """ raise _verify.blocked( - "langchain_mcp_adapters.client.MultiServerMCPClient binding " - "(docs/verified-apis.md §9)." + "langchain_mcp_adapters.client.MultiServerMCPClient binding (docs/verified-apis.md §9)." ) def adk(self) -> list[object]: # -> list[McpToolset] diff --git a/python/tests/conformance/contract_drivers.py b/python/tests/conformance/contract_drivers.py index beefd171..0272bdb3 100644 --- a/python/tests/conformance/contract_drivers.py +++ b/python/tests/conformance/contract_drivers.py @@ -202,47 +202,86 @@ def _llamaindex_sync(d: Donkey) -> None: driver.adapter + "." + driver.factory: driver for driver in [ Driver( - "langgraph", "chat_model", "langchain_openai", _langgraph_call, "raised", - stream=_langgraph_stream, sync=_langgraph_sync, + "langgraph", + "chat_model", + "langchain_openai", + _langgraph_call, + "raised", + stream=_langgraph_stream, + sync=_langgraph_sync, ), Driver( - "adk", "model", "google.adk.models.lite_llm", - lambda d: _adk(d.adk.model("m")), "classify", + "adk", + "model", + "google.adk.models.lite_llm", + lambda d: _adk(d.adk.model("m")), + "classify", stream=lambda d: _adk(d.adk.model("m"), stream=True), no_sync=_ASYNC_ONLY.format(fw="ADK's BaseLlm"), ), Driver( - "adk", "gemini", "google.adk.models", - lambda d: _adk(d.adk.gemini("gemini-2.5-flash")), "classify", + "adk", + "gemini", + "google.adk.models", + lambda d: _adk(d.adk.gemini("gemini-2.5-flash")), + "classify", stream=lambda d: _adk(d.adk.gemini("gemini-2.5-flash"), stream=True), no_sync=_ASYNC_ONLY.format(fw="ADK's BaseLlm"), ), Driver( - "strands", "model", "strands.models.openai", _strands_call, "classify", - stream=_strands_stream, sync=_strands_sync, + "strands", + "model", + "strands.models.openai", + _strands_call, + "classify", + stream=_strands_stream, + sync=_strands_sync, ), Driver( - "agent_framework", "chat_client", "agent_framework.openai", _maf_call, "raised", - stream=_maf_stream, no_sync=_ASYNC_ONLY.format(fw="Agent Framework's chat client"), + "agent_framework", + "chat_client", + "agent_framework.openai", + _maf_call, + "raised", + stream=_maf_stream, + no_sync=_ASYNC_ONLY.format(fw="Agent Framework's chat client"), ), Driver( - "openai_agents", "model", "agents", _agents_call, "classify", - stream=_agents_stream, no_sync=_ASYNC_ONLY.format(fw="The Agents SDK's Model"), + "openai_agents", + "model", + "agents", + _agents_call, + "classify", + stream=_agents_stream, + no_sync=_ASYNC_ONLY.format(fw="The Agents SDK's Model"), ), Driver( - "anthropic", "client", "anthropic", _anthropic_call, "classify", + "anthropic", + "client", + "anthropic", + _anthropic_call, + "classify", stream=_anthropic_stream, no_sync="donkey.anthropic.client() returns AsyncAnthropic only; there is no " "sync factory.", ), Driver( - "crewai", "llm", "crewai", _crewai_call, "classify", - stream=_crewai_stream, sync=_crewai_sync, + "crewai", + "llm", + "crewai", + _crewai_call, + "classify", + stream=_crewai_stream, + sync=_crewai_sync, ), Driver( - "llamaindex", "llm", "llama_index.llms.openai_like", _llamaindex_call, "raised", - stream=_llamaindex_stream, sync=_llamaindex_sync, + "llamaindex", + "llm", + "llama_index.llms.openai_like", + _llamaindex_call, + "raised", + stream=_llamaindex_stream, + sync=_llamaindex_sync, ), ] } - diff --git a/python/tests/conformance/contract_gateway.py b/python/tests/conformance/contract_gateway.py index fc9bb161..937f37e3 100644 --- a/python/tests/conformance/contract_gateway.py +++ b/python/tests/conformance/contract_gateway.py @@ -60,18 +60,47 @@ def _route(name: str) -> tuple[dict[str, str], bytes]: def _anthropic_stream(model: str) -> bytes: events: list[tuple[str | None, Any]] = [ - ("message_start", {"type": "message_start", "message": { - "id": "msg_contract", "type": "message", "role": "assistant", "model": model, - "content": [], "stop_reason": None, "stop_sequence": None, - "usage": {"input_tokens": 1, "output_tokens": 1}}}), - ("content_block_start", {"type": "content_block_start", "index": 0, - "content_block": {"type": "text", "text": ""}}), - ("content_block_delta", {"type": "content_block_delta", "index": 0, - "delta": {"type": "text_delta", "text": "PONG"}}), + ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_contract", + "type": "message", + "role": "assistant", + "model": model, + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + ), + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "PONG"}, + }, + ), ("content_block_stop", {"type": "content_block_stop", "index": 0}), - ("message_delta", {"type": "message_delta", - "delta": {"stop_reason": "end_turn", "stop_sequence": None}, - "usage": {"output_tokens": 1}}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 1}, + }, + ), ("message_stop", {"type": "message_stop"}), ] return _sse(events) diff --git a/python/tests/conformance/test_adapter_contract.py b/python/tests/conformance/test_adapter_contract.py index 8852fbe8..4207fbe5 100644 --- a/python/tests/conformance/test_adapter_contract.py +++ b/python/tests/conformance/test_adapter_contract.py @@ -54,6 +54,7 @@ _CORRELATION_HEADER = "x-correlation-id" _EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + @pytest.fixture(scope="module") def gateway() -> Iterator[Gateway]: gw = Gateway() @@ -254,4 +255,3 @@ async def test_example_build_passes_the_conformance_kit( failed = [r for r in results if r.status == "fail"] assert failed == [], failed assert {r.scenario for r in results if r.status == "exempt"} == set(known or {}) - diff --git a/python/tests/conformance/test_langgraph_conformance.py b/python/tests/conformance/test_langgraph_conformance.py index 64738e32..5f2bdbf7 100644 --- a/python/tests/conformance/test_langgraph_conformance.py +++ b/python/tests/conformance/test_langgraph_conformance.py @@ -39,9 +39,7 @@ def _load_example_build() -> Any: """Import the shipped example's ``build`` factory by file path (examples are not an installed package), so conformance runs against the real example.""" - example = ( - Path(__file__).resolve().parents[2] / "examples" / "langgraph" / "main.py" - ) + example = Path(__file__).resolve().parents[2] / "examples" / "langgraph" / "main.py" spec = importlib.util.spec_from_file_location("ddk_example_langgraph", example) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) diff --git a/python/tests/conformance/test_stock_openai_honesty.py b/python/tests/conformance/test_stock_openai_honesty.py index 47f712d8..4d17cff5 100644 --- a/python/tests/conformance/test_stock_openai_honesty.py +++ b/python/tests/conformance/test_stock_openai_honesty.py @@ -88,9 +88,7 @@ async def test_stock_openai_client_sees_the_honesty_header_on_the_happy_path() - the captured success body byte-for-byte.""" client = _stock_client() async with client: - raw = await client.responses.with_raw_response.create( - model="gpt-5.1", input="ping" - ) + raw = await client.responses.with_raw_response.create(model="gpt-5.1", input="ping") http_response = raw.http_response assert http_response.status_code == 200 assert http_response.headers.get(SIMULATOR_HEADER) == "true" diff --git a/python/tests/conformance/test_transport_exemptions.py b/python/tests/conformance/test_transport_exemptions.py index 9524072c..0813de5f 100644 --- a/python/tests/conformance/test_transport_exemptions.py +++ b/python/tests/conformance/test_transport_exemptions.py @@ -64,9 +64,7 @@ def _exempted(scenario: str) -> set[str]: def test_scenario_is_registered() -> None: - assert {_GATEWAY_SCENARIO, _CORRELATION_SCENARIO, _JWT_SCENARIO} <= set( - CONFORMANCE_SCENARIOS - ) + assert {_GATEWAY_SCENARIO, _CORRELATION_SCENARIO, _JWT_SCENARIO} <= set(CONFORMANCE_SCENARIOS) def _test_functions(path: Path) -> set[str]: diff --git a/python/tests/typecheck/asset_type_contract.py b/python/tests/typecheck/asset_type_contract.py index 0fc68137..d96e356b 100644 --- a/python/tests/typecheck/asset_type_contract.py +++ b/python/tests/typecheck/asset_type_contract.py @@ -7,9 +7,7 @@ discovery_type: AssetType = "mcp" publication_type: AssetType = PublicationAssetType.MCP_SERVER.value -asset_ref = AssetRef( - group_id="com.acme", asset_id="tools", version="1.0.0", type=discovery_type -) +asset_ref = AssetRef(group_id="com.acme", asset_id="tools", version="1.0.0", type=discovery_type) publication = Publication( asset_type=PublicationAssetType.MCP_SERVER, diff --git a/python/tests/unit/_anthropic_wire.py b/python/tests/unit/_anthropic_wire.py index a28416f7..9faa946b 100644 --- a/python/tests/unit/_anthropic_wire.py +++ b/python/tests/unit/_anthropic_wire.py @@ -36,26 +36,51 @@ ) _SSE_EVENTS = [ - ("message_start", {"type": "message_start", "message": { - "id": "msg_1", "type": "message", "role": "assistant", "model": MODEL, - "content": [], "stop_reason": None, "stop_sequence": None, - "usage": {"input_tokens": 16, "cache_creation_input_tokens": 100, - "cache_read_input_tokens": 2000, "output_tokens": 1}}}), - ("content_block_start", {"type": "content_block_start", "index": 0, - "content_block": {"type": "text", "text": ""}}), - ("content_block_delta", {"type": "content_block_delta", "index": 0, - "delta": {"type": "text_delta", "text": "PO"}}), - ("content_block_delta", {"type": "content_block_delta", "index": 0, - "delta": {"type": "text_delta", "text": "NG"}}), + ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": MODEL, + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": { + "input_tokens": 16, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 2000, + "output_tokens": 1, + }, + }, + }, + ), + ( + "content_block_start", + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + ), + ( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "PO"}}, + ), + ( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "NG"}}, + ), ("content_block_stop", {"type": "content_block_stop", "index": 0}), - ("message_delta", {"type": "message_delta", - "delta": {"stop_reason": "end_turn", "stop_sequence": None}, - "usage": {"output_tokens": 6}}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 6}, + }, + ), ("message_stop", {"type": "message_stop"}), ] -SSE_CHUNKS = [ - f"event: {name}\ndata: {json.dumps(data)}\n\n".encode() for name, data in _SSE_EVENTS -] +SSE_CHUNKS = [f"event: {name}\ndata: {json.dumps(data)}\n\n".encode() for name, data in _SSE_EVENTS] def success_response() -> httpx.Response: @@ -67,7 +92,11 @@ def success_response() -> httpx.Response: _LLM_PROXY = ( - Path(__file__).resolve().parents[2] / "src" / "donkey_kit" / "simulator" / "_fixtures" + Path(__file__).resolve().parents[2] + / "src" + / "donkey_kit" + / "simulator" + / "_fixtures" / "anypoint" / "llm_proxy" ) diff --git a/python/tests/unit/test_adapter_ergonomics.py b/python/tests/unit/test_adapter_ergonomics.py index 7faf1970..5a25870c 100644 --- a/python/tests/unit/test_adapter_ergonomics.py +++ b/python/tests/unit/test_adapter_ergonomics.py @@ -514,9 +514,7 @@ def _set_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.parametrize("f", _FACTORIES, ids=lambda f: f.module) -def test_factory_and_connection_kwargs_do_not_drift( - f: _F, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_factory_and_connection_kwargs_do_not_drift(f: _F, monkeypatch: pytest.MonkeyPatch) -> None: _set_proxy_env(monkeypatch) mod = importlib.import_module(f"donkey_kit.integrations.{f.module}") factory = getattr(mod, f.factory) diff --git a/python/tests/unit/test_adapter_openai3_bridge.py b/python/tests/unit/test_adapter_openai3_bridge.py index 8f45bb56..94169a8f 100644 --- a/python/tests/unit/test_adapter_openai3_bridge.py +++ b/python/tests/unit/test_adapter_openai3_bridge.py @@ -110,9 +110,7 @@ def _forwards_to(client: Any) -> Any: @pytest.mark.parametrize("name", sorted(_SLOTS)) -async def test_openai3_gets_the_reusable_bridge( - name: str, monkeypatch: pytest.MonkeyPatch -) -> None: +async def test_openai3_gets_the_reusable_bridge(name: str, monkeypatch: pytest.MonkeyPatch) -> None: httpx2 = pytest.importorskip("httpx2") monkeypatch.setattr(llm_client, "_openai_on_httpx2", lambda: True) donkey = _donkey([]) diff --git a/python/tests/unit/test_architecture.py b/python/tests/unit/test_architecture.py index 9a5fc128..7a4cd7b0 100644 --- a/python/tests/unit/test_architecture.py +++ b/python/tests/unit/test_architecture.py @@ -326,8 +326,8 @@ def test_the_private_import_scan_allows_seams_and_same_package_imports( # shrinks the file, and drop the entry once the file is within budget. The # transport split (#728) left no transport module past it. _OVERSIZED_CORE_MODULES = { - "config.py": 1108, - "errors.py": 1095, + "config.py": 1104, + "errors.py": 1087, "lastcall.py": 828, "telemetry.py": 657, } diff --git a/python/tests/unit/test_budget.py b/python/tests/unit/test_budget.py index 8483ee27..be470d26 100644 --- a/python/tests/unit/test_budget.py +++ b/python/tests/unit/test_budget.py @@ -21,7 +21,11 @@ _FIXED_NOW = datetime(2026, 9, 8, 14, 0, 0, tzinfo=timezone.utc) _FIXTURES = ( - Path(__file__).resolve().parents[2] / "src" / "donkey_kit" / "simulator" / "_fixtures" + Path(__file__).resolve().parents[2] + / "src" + / "donkey_kit" + / "simulator" + / "_fixtures" / "anypoint" / "llm_proxy" ) diff --git a/python/tests/unit/test_cli_doctor.py b/python/tests/unit/test_cli_doctor.py index 58e92171..d2f13123 100644 --- a/python/tests/unit/test_cli_doctor.py +++ b/python/tests/unit/test_cli_doctor.py @@ -44,17 +44,18 @@ def llm_env(monkeypatch: pytest.MonkeyPatch, tmp_path: object) -> None: def _patch_probe(monkeypatch: pytest.MonkeyPatch, result: ProbeResult) -> None: - monkeypatch.setattr( - "donkey_kit.cli.doctor._live_probe", lambda _c, _m: result - ) + monkeypatch.setattr("donkey_kit.cli.doctor._live_probe", lambda _c, _m: result) def test_incomplete_config_exits_1_and_names_the_var( monkeypatch: pytest.MonkeyPatch, tmp_path: object ) -> None: monkeypatch.chdir(tmp_path) # type: ignore[arg-type] - for var in ("DONKEY_LLM_PROXY_URL", "DONKEY_LLM_PROXY_CLIENT_ID", - "DONKEY_LLM_PROXY_CLIENT_SECRET"): + for var in ( + "DONKEY_LLM_PROXY_URL", + "DONKEY_LLM_PROXY_CLIENT_ID", + "DONKEY_LLM_PROXY_CLIENT_SECRET", + ): monkeypatch.delenv(var, raising=False) result = runner.invoke(app, ["doctor"]) @@ -105,9 +106,7 @@ def test_all_ok_exits_0(monkeypatch: pytest.MonkeyPatch, llm_env: None) -> None: assert "observed" in out -def test_json_output_is_machine_readable( - monkeypatch: pytest.MonkeyPatch, llm_env: None -) -> None: +def test_json_output_is_machine_readable(monkeypatch: pytest.MonkeyPatch, llm_env: None) -> None: _patch_probe(monkeypatch, ProbeResult(AuthError("nope"), None)) result = runner.invoke(app, ["doctor", "--json"]) diff --git a/python/tests/unit/test_cli_mock.py b/python/tests/unit/test_cli_mock.py index 0f643885..bd83668e 100644 --- a/python/tests/unit/test_cli_mock.py +++ b/python/tests/unit/test_cli_mock.py @@ -58,9 +58,7 @@ def _spy(**kwargs: object) -> None: def test_mock_defaults_bind_localhost_8080(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} - monkeypatch.setattr( - "donkey_kit.simulator.server.serve", lambda **kw: captured.update(kw) - ) + monkeypatch.setattr("donkey_kit.simulator.server.serve", lambda **kw: captured.update(kw)) result = runner.invoke(app, ["mock"]) assert result.exit_code == 0 @@ -78,9 +76,7 @@ def test_mock_parses_scenarios_into_config(monkeypatch: pytest.MonkeyPatch) -> N ) captured: dict[str, object] = {} - monkeypatch.setattr( - "donkey_kit.simulator.server.serve", lambda **kw: captured.update(kw) - ) + monkeypatch.setattr("donkey_kit.simulator.server.serve", lambda **kw: captured.update(kw)) result = runner.invoke( app, [ @@ -103,9 +99,7 @@ def test_mock_parses_scenarios_into_config(monkeypatch: pytest.MonkeyPatch) -> N def test_mock_invalid_scenario_exits_2(monkeypatch: pytest.MonkeyPatch) -> None: """A malformed --scenario is a usage error (exit 2), not a stack trace.""" - monkeypatch.setattr( - "donkey_kit.simulator.server.serve", lambda **kw: None - ) + monkeypatch.setattr("donkey_kit.simulator.server.serve", lambda **kw: None) result = runner.invoke(app, ["mock", "--scenario", "nonsense:foo=1"]) assert result.exit_code == 2 diff --git a/python/tests/unit/test_config.py b/python/tests/unit/test_config.py index ec3bea64..935f5241 100644 --- a/python/tests/unit/test_config.py +++ b/python/tests/unit/test_config.py @@ -134,9 +134,7 @@ def test_install_global_env_overrides_toml(tmp_path, monkeypatch: pytest.MonkeyP # unless the developer opts into a hard error, through the normal precedence. -def test_on_model_substitution_defaults_to_off( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_on_model_substitution_defaults_to_off(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: _isolate_toml(tmp_path, monkeypatch) assert DonkeyConfig().on_model_substitution == "off" # dataclass default assert DonkeyConfig.from_env().on_model_substitution == "off" # resolved default @@ -183,9 +181,7 @@ def test_unknown_on_model_substitution_is_a_config_error( # durable wallet-selector client id. An unknown mode fails loudly at resolve time. -def test_llm_proxy_auth_defaults_to_client_id( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_llm_proxy_auth_defaults_to_client_id(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: _isolate_toml(tmp_path, monkeypatch) assert DonkeyConfig().llm_proxy_auth == "client-id" # dataclass default assert DonkeyConfig.from_env().llm_proxy_auth == "client-id" # resolved default @@ -197,9 +193,7 @@ def test_llm_proxy_auth_from_env(tmp_path, monkeypatch: pytest.MonkeyPatch) -> N assert DonkeyConfig.from_env().llm_proxy_auth == "jwt" -def test_llm_proxy_auth_is_case_insensitive( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_llm_proxy_auth_is_case_insensitive(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: _isolate_toml(tmp_path, monkeypatch) monkeypatch.setenv("DONKEY_LLM_PROXY_AUTH", "JWT") assert DonkeyConfig.from_env().llm_proxy_auth == "jwt" @@ -289,9 +283,7 @@ def test_cost_tags_from_toml_table(tmp_path, monkeypatch: pytest.MonkeyPatch) -> assert cfg.cost == CostTags(team="support", enduser_id="user-7") -def test_cost_env_overrides_toml_per_dimension( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_cost_env_overrides_toml_per_dimension(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: _isolate_toml( tmp_path, monkeypatch, @@ -312,9 +304,7 @@ def test_unknown_cost_key_in_toml_is_a_config_error( assert "teem" in str(exc.value) -def test_cost_header_name_overrides_resolve( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_cost_header_name_overrides_resolve(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: _isolate_toml(tmp_path, monkeypatch) monkeypatch.setenv("DONKEY_COST_TEAM_HEADER", "x-cost-team") monkeypatch.setenv("DONKEY_COST_PROJECT_HEADER", "x-cost-project") diff --git a/python/tests/unit/test_config_endpoint_trust.py b/python/tests/unit/test_config_endpoint_trust.py index 150c6e21..8dc8085e 100644 --- a/python/tests/unit/test_config_endpoint_trust.py +++ b/python/tests/unit/test_config_endpoint_trust.py @@ -264,7 +264,7 @@ def test_loopback_http_does_not_warn_with_the_switch_on( @pytest.mark.parametrize("filename", [_TOML, _LOCAL_TOML]) -@pytest.mark.parametrize("line", ['allow_http = true\n', 'DONKEY_ALLOW_HTTP = "1"\n']) +@pytest.mark.parametrize("line", ["allow_http = true\n", 'DONKEY_ALLOW_HTTP = "1"\n']) def test_allow_http_switch_cannot_come_from_a_config_file( project: Path, filename: str, line: str ) -> None: @@ -348,9 +348,7 @@ async def test_donkey_from_env_sends_no_credentials_to_a_project_base_url( seen: list[httpx.Request] = [] - async def _no_network( - self: httpx.AsyncHTTPTransport, request: httpx.Request - ) -> httpx.Response: + async def _no_network(self: httpx.AsyncHTTPTransport, request: httpx.Request) -> httpx.Response: seen.append(request) return httpx.Response(599) @@ -951,9 +949,7 @@ def _strings(value: object) -> list[str]: return [] -def test_asdict_round_trip_validates( - project: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_asdict_round_trip_validates(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DONKEY_LLM_PROXY_URL", "https://llm.example.test/") _env_llm_creds(monkeypatch) cfg = DonkeyConfig.from_env() @@ -1005,9 +1001,7 @@ def test_provenance_recorded_in_another_process_does_not_make_a_url_trusted( again.validated(need="llm") -def test_unreadable_provenance_is_refused( - project: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_unreadable_provenance_is_refused(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: as_dict = dataclasses.asdict(_project_url_env_secret(project, monkeypatch)) as_dict["_sources"] = {**as_dict["_sources"], "llm_proxy_url": "project"} @@ -1079,9 +1073,7 @@ def test_unknown_cost_key_in_the_local_overlay_is_still_an_error(project: Path) DonkeyConfig.from_env() -def test_local_overlay_linked_to_the_user_file_is_refused( - project: Path, tmp_path: Path -) -> None: +def test_local_overlay_linked_to_the_user_file_is_refused(project: Path, tmp_path: Path) -> None: """A committed link from the overlay to the user file would label the user file's credentials as the local overlay's.""" user = tmp_path / "xdg" / _TOML @@ -1120,9 +1112,7 @@ def test_link_that_stays_inside_the_working_directory_is_read(project: Path) -> assert cfg.source_of("application_name").kind == "local" -def test_env_wins_over_the_local_overlay( - project: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_env_wins_over_the_local_overlay(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write(project / _TOML, 'application_name = "from-project"\n') _write(project / _LOCAL_TOML, 'application_name = "from-local"\n') monkeypatch.setenv("DONKEY_APP_NAME", "from-env") diff --git a/python/tests/unit/test_config_precedence.py b/python/tests/unit/test_config_precedence.py index 78222919..57e64a9d 100644 --- a/python/tests/unit/test_config_precedence.py +++ b/python/tests/unit/test_config_precedence.py @@ -142,9 +142,7 @@ def test_cost_tables_merge_across_all_three_files(layers: dict[str, Path]) -> No # --- path= --------------------------------------------------------------------------------------- -def test_path_replaces_the_working_directory_files( - layers: dict[str, Path], tmp_path: Path -) -> None: +def test_path_replaces_the_working_directory_files(layers: dict[str, Path], tmp_path: Path) -> None: _write(layers["project"], environment="from-cwd", org_id="cwd-org") _write(layers["user"], org_id="user-org") other = tmp_path / "elsewhere" diff --git a/python/tests/unit/test_config_validation.py b/python/tests/unit/test_config_validation.py index 87d02e5c..169d4a9e 100644 --- a/python/tests/unit/test_config_validation.py +++ b/python/tests/unit/test_config_validation.py @@ -134,8 +134,16 @@ def test_misspelt_boolean_raises_config_error(clean_env: pytest.MonkeyPatch) -> @pytest.mark.parametrize( ("raw", "expected"), - [("1", True), ("TRUE", True), (" yes ", True), ("on", True), - ("0", False), ("False", False), ("no", False), ("off", False)], + [ + ("1", True), + ("TRUE", True), + (" yes ", True), + ("on", True), + ("0", False), + ("False", False), + ("no", False), + ("off", False), + ], ) def test_boolean_tokens_parse(clean_env: pytest.MonkeyPatch, raw: str, expected: bool) -> None: clean_env.setenv("DONKEY_TELEMETRY", raw) @@ -207,8 +215,6 @@ async def test_async_retry_loop_with_no_attempts_raises_config_error() -> None: def test_sync_retry_loop_with_no_attempts_raises_config_error() -> None: - with DonkeyClient( - _negative_retries(), transport=httpx.MockTransport(_never_called) - ) as client: + with DonkeyClient(_negative_retries(), transport=httpx.MockTransport(_never_called)) as client: with pytest.raises(ConfigError, match="max_retries is -1"): client.get("https://proxy/thing") diff --git a/python/tests/unit/test_conformance.py b/python/tests/unit/test_conformance.py index c7dbade8..1f029119 100644 --- a/python/tests/unit/test_conformance.py +++ b/python/tests/unit/test_conformance.py @@ -303,6 +303,7 @@ def factory(*, donkey: Donkey) -> str: def test_build_agent_calls_zero_arg_factory_without_donkey() -> None: fab = _fresh_donkey() try: + def factory() -> str: return "agent" @@ -428,13 +429,11 @@ async def test_harness_restores_real_transports_after_the_run( ) -> None: stacks = harness_module._installed_http_stacks() before = [ - (s.HTTPTransport.handle_request, s.AsyncHTTPTransport.handle_async_request) - for s in stacks + (s.HTTPTransport.handle_request, s.AsyncHTTPTransport.handle_async_request) for s in stacks ] await run_conformance(OwnHttpxAgent) after = [ - (s.HTTPTransport.handle_request, s.AsyncHTTPTransport.handle_async_request) - for s in stacks + (s.HTTPTransport.handle_request, s.AsyncHTTPTransport.handle_async_request) for s in stacks ] assert after == before diff --git a/python/tests/unit/test_conformance_plugin.py b/python/tests/unit/test_conformance_plugin.py index d22d583b..b0c5d4cf 100644 --- a/python/tests/unit/test_conformance_plugin.py +++ b/python/tests/unit/test_conformance_plugin.py @@ -33,7 +33,7 @@ # A well-behaved agent module: bridges refusals with classify(), never retries, # logs the correlation id, tolerates an absent budget. Passes all four scenarios. -_GOOD_AGENT = ''' +_GOOD_AGENT = """ import logging import openai from donkey_kit.core.errors import classify @@ -60,10 +60,10 @@ async def run(self, prompt): def build(donkey): return Agent(donkey) -''' +""" # The headline bug: retries a terminal 429 budget refusal. Fails the retry scenario. -_RETRY_BUG_AGENT = ''' +_RETRY_BUG_AGENT = """ import openai from donkey_kit.core.errors import classify @@ -85,17 +85,17 @@ async def run(self, prompt): def build(donkey): return Agent(donkey) -''' +""" # No openai import on purpose: the invalid-exemption path must fail at # validation, not because the module could not be imported. -_BAD_KNOWN_LIMITATIONS_AGENT = ''' +_BAD_KNOWN_LIMITATIONS_AGENT = """ KNOWN_LIMITATIONS = {"not_a_real_scenario": "this key names no scenario"} def build(donkey): return object() -''' +""" def test_plugin_is_inert_without_the_flag(pytester: pytest.Pytester) -> None: diff --git a/python/tests/unit/test_cost.py b/python/tests/unit/test_cost.py index e6bbfc96..c1ca0ac2 100644 --- a/python/tests/unit/test_cost.py +++ b/python/tests/unit/test_cost.py @@ -81,10 +81,10 @@ def test_merge_prefers_the_other_set_fields_per_field() -> None: base = CostTags(team="support", project="triage-v2", env="prod") override = CostTags(project="triage-v3", enduser_id="u-9") merged = base.merge(override) - assert merged.team == "support" # kept from base - assert merged.project == "triage-v3" # overridden - assert merged.env == "prod" # kept from base - assert merged.enduser_id == "u-9" # added by override + assert merged.team == "support" # kept from base + assert merged.project == "triage-v3" # overridden + assert merged.env == "prod" # kept from base + assert merged.enduser_id == "u-9" # added by override def test_merge_with_empty_is_identity() -> None: diff --git a/python/tests/unit/test_credential_binding_planes.py b/python/tests/unit/test_credential_binding_planes.py index 0d51883b..dcc6d318 100644 --- a/python/tests/unit/test_credential_binding_planes.py +++ b/python/tests/unit/test_credential_binding_planes.py @@ -124,7 +124,9 @@ async def test_control_plane_call_sends_neither_secret_nor_token_to_a_project_ba async with Donkey.from_env() as donkey: assert donkey._owned_auth_http is not None assert donkey._owned_auth_http._control_plane - donkey._owned_auth_http.governed_transport.replace_inner(httpx.MockTransport(token_endpoint)) + donkey._owned_auth_http.governed_transport.replace_inner( + httpx.MockTransport(token_endpoint) + ) donkey.registry._http.governed_transport.replace_inner(httpx.MockTransport(platform)) with pytest.raises(ConfigError, match="cp.example.test"): await donkey.registry._http.get(f"{_PROJECT_CP}/exchange/api/v2/assets") @@ -142,7 +144,9 @@ async def test_control_plane_call_with_file_credentials_reaches_a_project_base_u async with Donkey.from_env() as donkey: assert donkey._owned_auth_http is not None - donkey._owned_auth_http.governed_transport.replace_inner(httpx.MockTransport(token_endpoint)) + donkey._owned_auth_http.governed_transport.replace_inner( + httpx.MockTransport(token_endpoint) + ) donkey.registry._http.governed_transport.replace_inner(httpx.MockTransport(platform)) await donkey.registry._http.get(f"{_PROJECT_CP}/exchange/api/v2/assets") diff --git a/python/tests/unit/test_credential_planes.py b/python/tests/unit/test_credential_planes.py index fcdc7d57..e3998a7b 100644 --- a/python/tests/unit/test_credential_planes.py +++ b/python/tests/unit/test_credential_planes.py @@ -285,7 +285,9 @@ async def test_control_plane_request_carries_the_connected_app_token_in_client_i token_endpoint, platform = _TokenEndpoint(), _Recorder() async with Donkey(_client_id_cfg()) as donkey: assert donkey._owned_auth_http is not None - donkey._owned_auth_http.governed_transport.replace_inner(httpx.MockTransport(token_endpoint)) + donkey._owned_auth_http.governed_transport.replace_inner( + httpx.MockTransport(token_endpoint) + ) donkey.registry._http.governed_transport.replace_inner(httpx.MockTransport(platform)) await donkey.registry._http.get(f"{_CONTROL_PLANE}/exchange/api/v2/assets") @@ -298,7 +300,9 @@ async def test_control_plane_request_carries_the_connected_app_token_in_jwt_mode token_endpoint, platform = _TokenEndpoint(), _Recorder() async with Donkey(_jwt_cfg(), llm_auth=StaticToken(_WALLET_JWT)) as donkey: assert donkey._owned_auth_http is not None - donkey._owned_auth_http.governed_transport.replace_inner(httpx.MockTransport(token_endpoint)) + donkey._owned_auth_http.governed_transport.replace_inner( + httpx.MockTransport(token_endpoint) + ) donkey.registry._http.governed_transport.replace_inner(httpx.MockTransport(platform)) await donkey.registry._http.get(f"{_CONTROL_PLANE}/exchange/api/v2/assets") @@ -312,7 +316,9 @@ async def test_connected_app_token_request_carries_no_data_plane_credentials() - token_endpoint = _TokenEndpoint() async with Donkey(_jwt_cfg(), llm_auth=StaticToken(_WALLET_JWT)) as donkey: assert donkey._owned_auth_http is not None and donkey._auth is not None - donkey._owned_auth_http.governed_transport.replace_inner(httpx.MockTransport(token_endpoint)) + donkey._owned_auth_http.governed_transport.replace_inner( + httpx.MockTransport(token_endpoint) + ) assert await donkey._auth.token() == _CONTROL_PLANE_TOKEN (token_request,) = token_endpoint.requests diff --git a/python/tests/unit/test_data_plane_only_headers.py b/python/tests/unit/test_data_plane_only_headers.py index 99b2b4c4..394b770c 100644 --- a/python/tests/unit/test_data_plane_only_headers.py +++ b/python/tests/unit/test_data_plane_only_headers.py @@ -74,7 +74,9 @@ async def _send_all() -> tuple[httpx.Request, httpx.Request, httpx.Request]: token_endpoint, platform, proxy = _Recorder(), _Recorder(), _Recorder() async with Donkey(_cfg()) as donkey: assert donkey._owned_auth_http is not None - donkey._owned_auth_http.governed_transport.replace_inner(httpx.MockTransport(token_endpoint)) + donkey._owned_auth_http.governed_transport.replace_inner( + httpx.MockTransport(token_endpoint) + ) donkey.registry._http.governed_transport.replace_inner(httpx.MockTransport(platform)) donkey._http.governed_transport.replace_inner(httpx.MockTransport(proxy)) async with donkey.run(team=_RUN_TEAM, enduser_id=_ENDUSER): diff --git a/python/tests/unit/test_decorators.py b/python/tests/unit/test_decorators.py index c2d02f3f..f183d768 100644 --- a/python/tests/unit/test_decorators.py +++ b/python/tests/unit/test_decorators.py @@ -188,7 +188,7 @@ def test_tool_with_blank_docstring_is_flagged() -> None: @Donkey.tool def lookup_crm() -> None: - """ """ + """ """ def test_registered_tools_reflects_decoration_order() -> None: diff --git a/python/tests/unit/test_default_runtime.py b/python/tests/unit/test_default_runtime.py index 41036b96..42c2644f 100644 --- a/python/tests/unit/test_default_runtime.py +++ b/python/tests/unit/test_default_runtime.py @@ -75,9 +75,7 @@ def test_standalone_owner_closes_only_its_blocking_client(owner: str) -> None: cfg = DonkeyConfig(llm_proxy_url="https://proxy") shared = DonkeyAsyncClient(cfg, None) instance = ( - _adapter_cls("langgraph")(cfg, shared) - if owner == "adapter" - else LLMClient(cfg, shared) + _adapter_cls("langgraph")(cfg, shared) if owner == "adapter" else LLMClient(cfg, shared) ) blocking = instance._sync_http() @@ -93,9 +91,7 @@ def test_closing_standalone_owner_without_a_blocking_client_is_safe(owner: str) cfg = DonkeyConfig(llm_proxy_url="https://proxy") shared = DonkeyAsyncClient(cfg, None) instance = ( - _adapter_cls("langgraph")(cfg, shared) - if owner == "adapter" - else LLMClient(cfg, shared) + _adapter_cls("langgraph")(cfg, shared) if owner == "adapter" else LLMClient(cfg, shared) ) instance.close() @@ -125,9 +121,7 @@ async def test_standalone_async_close_closes_owned_blocking_client(owner: str) - cfg = DonkeyConfig(llm_proxy_url="https://proxy") shared = DonkeyAsyncClient(cfg, None) instance = ( - _adapter_cls("langgraph")(cfg, shared) - if owner == "adapter" - else LLMClient(cfg, shared) + _adapter_cls("langgraph")(cfg, shared) if owner == "adapter" else LLMClient(cfg, shared) ) blocking = instance._sync_http() diff --git a/python/tests/unit/test_docs_from_env_kwargs.py b/python/tests/unit/test_docs_from_env_kwargs.py index 02d8b032..2eaae135 100644 --- a/python/tests/unit/test_docs_from_env_kwargs.py +++ b/python/tests/unit/test_docs_from_env_kwargs.py @@ -63,6 +63,4 @@ def named(func: object) -> set[str]: def test_the_guard_sees_the_docs() -> None: # The cost-tag snippet on the telemetry page must be found, or the glob broke. - assert any( - "Donkey.from_env(team=" in p.read_text(encoding="utf-8") for p in _doc_files() - ) + assert any("Donkey.from_env(team=" in p.read_text(encoding="utf-8") for p in _doc_files()) diff --git a/python/tests/unit/test_doctor.py b/python/tests/unit/test_doctor.py index 67a643b8..25764d34 100644 --- a/python/tests/unit/test_doctor.py +++ b/python/tests/unit/test_doctor.py @@ -40,7 +40,12 @@ #: Live captures from ``ddk-azure-openai-model-routing`` (2026-10-07, #896). _AZURE = ( Path(__file__).resolve().parents[2] - / "src" / "donkey_kit" / "simulator" / "_fixtures" / "anypoint" / "azure_openai_routing" + / "src" + / "donkey_kit" + / "simulator" + / "_fixtures" + / "anypoint" + / "azure_openai_routing" ) @@ -69,8 +74,11 @@ def test_incomplete_config_skips_probe_and_lists_missing_fields( monkeypatch: pytest.MonkeyPatch, tmp_path: object ) -> None: monkeypatch.chdir(tmp_path) # type: ignore[arg-type] - for var in ("DONKEY_LLM_PROXY_URL", "DONKEY_LLM_PROXY_CLIENT_ID", - "DONKEY_LLM_PROXY_CLIENT_SECRET"): + for var in ( + "DONKEY_LLM_PROXY_URL", + "DONKEY_LLM_PROXY_CLIENT_ID", + "DONKEY_LLM_PROXY_CLIENT_SECRET", + ): monkeypatch.delenv(var, raising=False) # A probe that would explode if called — proves the gate short-circuits. @@ -117,8 +125,7 @@ def test_wrong_credentials_diagnosed_as_auth(llm_env: None) -> None: def test_model_not_allowed_diagnosed_from_verified_passthrough(llm_env: None) -> None: err = UpstreamRequestError( - "The upstream model provider rejected the request (400): " - "model 'gpt-4o' does not exist.", + "The upstream model provider rejected the request (400): model 'gpt-4o' does not exist.", code="model_not_found", param="model", ) @@ -135,9 +142,7 @@ def test_model_not_allowed_diagnosed_from_verified_passthrough(llm_env: None) -> def test_non_model_typed_error_leaves_model_ok_and_notes_it(llm_env: None) -> None: """A policy refusal on the probe means auth + model were accepted; it's surfaced on its own [i] line, never mis-attributed to credentials or model.""" - checks = run_diagnostics( - "gpt-4o", probe=_probe(ProbeResult(PIIDetected("blocked"), None)) - ) + checks = run_diagnostics("gpt-4o", probe=_probe(ProbeResult(PIIDetected("blocked"), None))) assert _by_name(checks, "credentials").level is Level.OK assert _by_name(checks, "model").level is Level.OK assert _by_name(checks, "policy").level is Level.INFO @@ -276,9 +281,7 @@ def test_routed_404_on_both_routes_fails_the_model_line(llm_env: None) -> None: def test_routed_404_without_a_chat_probe_leaves_the_model_unchecked(llm_env: None) -> None: - checks = run_diagnostics( - "gpt-4o", probe=_probe(_rejected("reject.responses-not-served")) - ) + checks = run_diagnostics("gpt-4o", probe=_probe(_rejected("reject.responses-not-served"))) assert _by_name(checks, "model").level is Level.SKIP assert _by_name(checks, "responses").level is Level.INFO assert not doctor.has_failure(checks) @@ -390,9 +393,7 @@ def test_endpoint_source_labels( f'[donkey]\nllm_proxy_url = "{url}"\n' ) else: - (tmp_path / "xdg" / ".donkey-kit.toml").write_text( - f'[donkey]\nllm_proxy_url = "{url}"\n' - ) + (tmp_path / "xdg" / ".donkey-kit.toml").write_text(f'[donkey]\nllm_proxy_url = "{url}"\n') monkeypatch.setenv("DONKEY_LLM_PROXY_CLIENT_ID", "cid") monkeypatch.setenv("DONKEY_LLM_PROXY_CLIENT_SECRET", "secret") diff --git a/python/tests/unit/test_donkey_surface.py b/python/tests/unit/test_donkey_surface.py index 037e7c4a..c603bea4 100644 --- a/python/tests/unit/test_donkey_surface.py +++ b/python/tests/unit/test_donkey_surface.py @@ -56,7 +56,7 @@ def test_uninstalled_adapter_raises_curated_import_error( fab = Donkey(_cfg()) with pytest.raises(ImportError) as exc: _ = fab.langgraph - assert 'donkey-kit[langgraph]' in str(exc.value) + assert "donkey-kit[langgraph]" in str(exc.value) def test_unknown_attribute_raises_attribute_error() -> None: @@ -74,7 +74,7 @@ def test_openai_agents_adapter_import_error_names_the_new_extra( fab = Donkey(_cfg()) with pytest.raises(ImportError) as exc: _ = fab.openai_agents - assert 'donkey-kit[openai-agents]' in str(exc.value) + assert "donkey-kit[openai-agents]" in str(exc.value) def test_openai_is_a_real_method_not_the_agents_adapter() -> None: @@ -171,9 +171,7 @@ def test_run_without_id_generates_a_run_of_one() -> None: # --- cost-attribution tags on the public surface (docs/verified-apis.md §3, BG §1.7, #196) -------- -def test_from_env_sets_config_level_cost_tags( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_from_env_sets_config_level_cost_tags(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: from donkey_kit.core.cost import CostTags # Isolate from any stray .donkey-kit.toml / DONKEY_COST_* in the environment. @@ -191,9 +189,7 @@ def test_from_env_sets_config_level_cost_tags( assert fab.config.cost == CostTags(team="support", project="triage-v2", env="prod") -def test_from_env_kwargs_merge_over_env_tags( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_from_env_kwargs_merge_over_env_tags(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: from donkey_kit.core.cost import CostTags monkeypatch.chdir(tmp_path) @@ -208,9 +204,7 @@ def test_from_env_kwargs_merge_over_env_tags( # --- on_model_substitution override on the public surface (BG §1.1, #309) -------- -def test_from_env_sets_on_model_substitution( - tmp_path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_from_env_sets_on_model_substitution(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: # The kwarg opts a Donkey into model determinism without an env var / toml — # it merges over the resolved config the same way the cost tags do. monkeypatch.chdir(tmp_path) diff --git a/python/tests/unit/test_error_taxonomy.py b/python/tests/unit/test_error_taxonomy.py index f1fb3782..b84d1bef 100644 --- a/python/tests/unit/test_error_taxonomy.py +++ b/python/tests/unit/test_error_taxonomy.py @@ -53,9 +53,7 @@ def test_the_walk_finds_the_documented_tree() -> None: def test_every_error_is_exported_from_donkey_kit(cls: type[DonkeyError]) -> None: # Exactly one home: the stable namespace, or experimental for errors only a # verification-blocked surface raises (#730). - homes = [ - mod for mod in (donkey_kit, donkey_kit.experimental) if cls.__name__ in mod.__all__ - ] + homes = [mod for mod in (donkey_kit, donkey_kit.experimental) if cls.__name__ in mod.__all__] assert len(homes) == 1, f"{cls.__name__} exported from {homes}" assert getattr(homes[0], cls.__name__) is cls @@ -84,9 +82,7 @@ def test_every_error_ships_its_own_nonempty_default(cls: type[DonkeyError]) -> N @pytest.mark.parametrize("cls", _TREE, ids=lambda c: c.__name__) def test_every_error_accepts_a_remediation_override(cls: type[DonkeyError]) -> None: - assert _build(cls, remediation="do the specific thing").remediation == ( - "do the specific thing" - ) + assert _build(cls, remediation="do the specific thing").remediation == ("do the specific thing") @pytest.mark.parametrize("cls", _TREE, ids=lambda c: c.__name__) diff --git a/python/tests/unit/test_errors.py b/python/tests/unit/test_errors.py index 78dbb6e4..ce26af61 100644 --- a/python/tests/unit/test_errors.py +++ b/python/tests/unit/test_errors.py @@ -127,8 +127,12 @@ def test_request_rate_limit_retry_after_prefers_retry_after_header() -> None: err = classify( _resp( 429, - {"retry-after": "7", "x-ratelimit-limit": "3", "x-ratelimit-remaining": "0", - "x-ratelimit-reset": "40000"}, + { + "retry-after": "7", + "x-ratelimit-limit": "3", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "40000", + }, ) ) assert isinstance(err, RequestRateLimitExceeded) @@ -586,8 +590,14 @@ def test_openai_object_envelope_4xx_is_upstream_request_error() -> None: err = classify( _json_resp( 400, - {"error": {"message": "no such model", "type": "invalid_request_error", - "code": "model_not_found", "param": "model"}}, + { + "error": { + "message": "no such model", + "type": "invalid_request_error", + "code": "model_not_found", + "param": "model", + } + }, ) ) assert isinstance(err, UpstreamRequestError) @@ -607,10 +617,20 @@ def test_gemini_list_envelope_400_is_upstream_request_error_not_policy_violation err = classify( _json_list_resp( 400, - [{"error": {"code": 400, "message": "Missing or invalid Authorization header.", - "status": "INVALID_ARGUMENT"}}], - {"x-llm-proxy-model-based-routing-success": - "Request passed through without model-based routing."}, + [ + { + "error": { + "code": 400, + "message": "Missing or invalid Authorization header.", + "status": "INVALID_ARGUMENT", + } + } + ], + { + "x-llm-proxy-model-based-routing-success": ( + "Request passed through without model-based routing." + ) + }, ) ) assert isinstance(err, UpstreamRequestError) diff --git a/python/tests/unit/test_framework_retries.py b/python/tests/unit/test_framework_retries.py index 5978b931..556c6809 100644 --- a/python/tests/unit/test_framework_retries.py +++ b/python/tests/unit/test_framework_retries.py @@ -39,7 +39,11 @@ from donkey_kit.simulator.fixtures import parse_headers _FIXTURES = ( - Path(__file__).resolve().parents[2] / "src" / "donkey_kit" / "simulator" / "_fixtures" + Path(__file__).resolve().parents[2] + / "src" + / "donkey_kit" + / "simulator" + / "_fixtures" / "anypoint" / "llm_proxy" ) diff --git a/python/tests/unit/test_gemini_inbound_contract.py b/python/tests/unit/test_gemini_inbound_contract.py index 3b52f4bc..b7ab0137 100644 --- a/python/tests/unit/test_gemini_inbound_contract.py +++ b/python/tests/unit/test_gemini_inbound_contract.py @@ -35,9 +35,7 @@ def _headers(name: str) -> dict[str, str]: def _fixture_response(body: str, headers: str, status: int = 200) -> httpx.Response: - return httpx.Response( - status, content=(FIXTURES / body).read_bytes(), headers=_headers(headers) - ) + return httpx.Response(status, content=(FIXTURES / body).read_bytes(), headers=_headers(headers)) # --- the captured shapes ------------------------------------------------------ diff --git a/python/tests/unit/test_langgraph_sync_transport.py b/python/tests/unit/test_langgraph_sync_transport.py index 897e76f5..a064034f 100644 --- a/python/tests/unit/test_langgraph_sync_transport.py +++ b/python/tests/unit/test_langgraph_sync_transport.py @@ -166,7 +166,9 @@ async def test_last_call_observed_after_call(call: _Call) -> None: # of this context; the record must still reach the caller (#850). donkey = Donkey(_cfg()) donkey._http.governed_transport.replace_inner(httpx.MockTransport(_success_or_stream)) - donkey._sync_http_client().governed_transport.replace_inner(httpx.MockTransport(_success_or_stream)) + donkey._sync_http_client().governed_transport.replace_inner( + httpx.MockTransport(_success_or_stream) + ) async with donkey: await call(donkey.langgraph("gpt-4o")) assert donkey.last_call.status is LastCallStatus.OBSERVED diff --git a/python/tests/unit/test_langgraph_typed_refusals.py b/python/tests/unit/test_langgraph_typed_refusals.py index fdeb01d4..802f3b3b 100644 --- a/python/tests/unit/test_langgraph_typed_refusals.py +++ b/python/tests/unit/test_langgraph_typed_refusals.py @@ -75,7 +75,7 @@ def _pii_response() -> httpx.Response: ) return httpx.Response( 403, - json={"error": {"type": "pii_detected", "message": "blocked: [{\"pii_type\": \"EMAIL\"}]"}}, + json={"error": {"type": "pii_detected", "message": 'blocked: [{"pii_type": "EMAIL"}]'}}, request=request, ) diff --git a/python/tests/unit/test_lastcall.py b/python/tests/unit/test_lastcall.py index fbb390e3..20e411e5 100644 --- a/python/tests/unit/test_lastcall.py +++ b/python/tests/unit/test_lastcall.py @@ -607,9 +607,7 @@ def test_from_response_populates_usage_from_the_committed_fixture() -> None: # AC1: the three detail fields (and the totals) parse from the captured LIVE # fixture, with the fixture bytes themselves as the test input. fixture = load("success") - resp = httpx.Response( - 200, content=fixture.body, headers={"content-type": "application/json"} - ) + resp = httpx.Response(200, content=fixture.body, headers={"content-type": "application/json"}) record = LastCall.from_response(resp) assert record.status is LastCallStatus.OBSERVED assert record.input_tokens == 17 @@ -901,9 +899,7 @@ def test_semantic_routing_helper_reads_the_header() -> None: def test_from_response_surfaces_matched_topic_and_score() -> None: - resp = httpx.Response( - 200, headers={**_IDENTITY_HEADERS, _SEMANTIC_HEADER: _SEMANTIC_SUCCESS} - ) + resp = httpx.Response(200, headers={**_IDENTITY_HEADERS, _SEMANTIC_HEADER: _SEMANTIC_SUCCESS}) record = LastCall.from_response(resp) assert record.matched_topic == "Finance" assert record.routing_score == 0.62 @@ -926,6 +922,4 @@ def test_semantic_score_is_a_float() -> None: def test_no_unverified_warning_on_the_semantic_read_path() -> None: with warnings.catch_warnings(): warnings.simplefilter("error", UnverifiedValueWarning) - LastCall.from_response( - httpx.Response(200, headers={_SEMANTIC_HEADER: _SEMANTIC_SUCCESS}) - ) + LastCall.from_response(httpx.Response(200, headers={_SEMANTIC_HEADER: _SEMANTIC_SUCCESS})) diff --git a/python/tests/unit/test_llm_client_openai3_injection.py b/python/tests/unit/test_llm_client_openai3_injection.py index ade74222..f957ea53 100644 --- a/python/tests/unit/test_llm_client_openai3_injection.py +++ b/python/tests/unit/test_llm_client_openai3_injection.py @@ -183,9 +183,7 @@ async def test_an_async_streamed_call_closes_the_shared_response() -> None: chunks = _Chunks() shared = DonkeyAsyncClient(_CFG, None, transport=httpx.MockTransport(lambda r: _sse(chunks))) async with shared: - stream = await LLMClient(_CFG, shared).client().chat.completions.create( - **_HI, stream=True - ) + stream = await LLMClient(_CFG, shared).client().chat.completions.create(**_HI, stream=True) text = "".join([c.choices[0].delta.content or "" async for c in stream]) assert text == "hi there" assert chunks.closed diff --git a/python/tests/unit/test_llm_proxy_contract.py b/python/tests/unit/test_llm_proxy_contract.py index fd884624..3096a031 100644 --- a/python/tests/unit/test_llm_proxy_contract.py +++ b/python/tests/unit/test_llm_proxy_contract.py @@ -28,7 +28,11 @@ from donkey_kit.simulator.fixtures import parse_headers FIXTURES = ( - Path(__file__).resolve().parents[2] / "src" / "donkey_kit" / "simulator" / "_fixtures" + Path(__file__).resolve().parents[2] + / "src" + / "donkey_kit" + / "simulator" + / "_fixtures" / "anypoint" / "llm_proxy" ) diff --git a/python/tests/unit/test_logging.py b/python/tests/unit/test_logging.py index be753c61..6f58f68c 100644 --- a/python/tests/unit/test_logging.py +++ b/python/tests/unit/test_logging.py @@ -222,4 +222,3 @@ def test_otlp_bootstrap_inert_path_logs_debug( assert any("OTLP export not installed" in m for m in _debug(caplog)) # DEBUG only: "inert and silent" (BG §1.6) means no WARNING or louder. assert all(r.levelno == logging.DEBUG for r in caplog.records) - diff --git a/python/tests/unit/test_missing_framework_error.py b/python/tests/unit/test_missing_framework_error.py index 35ad453c..11c4b6d1 100644 --- a/python/tests/unit/test_missing_framework_error.py +++ b/python/tests/unit/test_missing_framework_error.py @@ -95,9 +95,9 @@ def exec_module(self, module: types.ModuleType) -> None: @pytest.fixture(params=["framework", "dependency"]) -def missing(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Iterator[ - Callable[[str], str] -]: +def missing( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> Iterator[Callable[[str], str]]: """Make a factory's lazy import fail: ``missing(module)`` blocks the module itself, or serves it with a dependency that is not installed. Returns the name the curated error should report as missing.""" diff --git a/python/tests/unit/test_model_wallet_contract.py b/python/tests/unit/test_model_wallet_contract.py index 1c81dcb8..2cf23e72 100644 --- a/python/tests/unit/test_model_wallet_contract.py +++ b/python/tests/unit/test_model_wallet_contract.py @@ -29,13 +29,15 @@ FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "anypoint" / "model_wallet" _LLM_PROXY = ( - Path(__file__).resolve().parents[2] / "src" / "donkey_kit" / "simulator" / "_fixtures" + Path(__file__).resolve().parents[2] + / "src" + / "donkey_kit" + / "simulator" + / "_fixtures" / "anypoint" / "llm_proxy" ) -_TOKEN_WINDOW_429 = parse_headers( - (_LLM_PROXY / "reject.token-rate-limit.headers.txt").read_text() -) +_TOKEN_WINDOW_429 = parse_headers((_LLM_PROXY / "reject.token-rate-limit.headers.txt").read_text()) def _headers(name: str) -> dict[str, str]: diff --git a/python/tests/unit/test_openai_gemini_stream_contract.py b/python/tests/unit/test_openai_gemini_stream_contract.py index cc5c467f..ec0a8b1a 100644 --- a/python/tests/unit/test_openai_gemini_stream_contract.py +++ b/python/tests/unit/test_openai_gemini_stream_contract.py @@ -25,9 +25,7 @@ from donkey_kit.core.transport import DonkeyAsyncClient from donkey_kit.simulator.fixtures import parse_headers -FIXTURES = ( - Path(__file__).resolve().parents[1] / "fixtures" / "anypoint" / "openai_gemini_stream" -) +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "anypoint" / "openai_gemini_stream" _CFG = DonkeyConfig(llm_proxy_url="https://proxy") _CHAT = "https://proxy/ddk-model-wallet/chat/completions" _BODY = { diff --git a/python/tests/unit/test_product_description.py b/python/tests/unit/test_product_description.py index 33d70c83..ba478850 100644 --- a/python/tests/unit/test_product_description.py +++ b/python/tests/unit/test_product_description.py @@ -17,9 +17,11 @@ def _opening_description(readme: Path) -> str: - return readme.read_text().split("# Donkey Development Kit", maxsplit=1)[1].split( - "> **Project status", maxsplit=1 - )[0] + return ( + readme.read_text() + .split("# Donkey Development Kit", maxsplit=1)[1] + .split("> **Project status", maxsplit=1)[0] + ) @pytest.mark.parametrize( diff --git a/python/tests/unit/test_public_api_surface.py b/python/tests/unit/test_public_api_surface.py index 645fc460..b681c793 100644 --- a/python/tests/unit/test_public_api_surface.py +++ b/python/tests/unit/test_public_api_surface.py @@ -209,4 +209,3 @@ def test_experimental_is_exactly_the_blocked_only_types() -> None: assert set(experimental.__all__) == _BLOCKED_ONLY for name in experimental.__all__: assert getattr(experimental, name) is not None - diff --git a/python/tests/unit/test_pure_logic.py b/python/tests/unit/test_pure_logic.py index e06d3e28..0a1e2bde 100644 --- a/python/tests/unit/test_pure_logic.py +++ b/python/tests/unit/test_pure_logic.py @@ -115,7 +115,7 @@ def test_description_quality_flags_tautological_and_missing() -> None: issues = check_description_quality( [ ("search_employees", "search employees"), # tautological after normalise - ("get_leave", None), # missing + ("get_leave", None), # missing ("ok_tool", "Fetch a well documented thing."), ] ) diff --git a/python/tests/unit/test_refusal_bridge.py b/python/tests/unit/test_refusal_bridge.py index 48b64c6e..17bb1cd7 100644 --- a/python/tests/unit/test_refusal_bridge.py +++ b/python/tests/unit/test_refusal_bridge.py @@ -344,8 +344,13 @@ def unwrap(exc: BaseException) -> DonkeyError | None: monkeypatch.setitem(sys.modules, module.__name__, module) spec = ADAPTERS["strands"] fake = type(spec)( - "fake", "._fake_framework_adapter", "Fake", "fake", - conformance_tested=False, probe=("_fake_framework",), refusal_translator="unwrap", + "fake", + "._fake_framework_adapter", + "Fake", + "fake", + conformance_tested=False, + probe=("_fake_framework",), + refusal_translator="unwrap", ) monkeypatch.setitem(ADAPTERS, "fake", fake) diff --git a/python/tests/unit/test_semantic_cache.py b/python/tests/unit/test_semantic_cache.py index 6e1a3655..e16f9d5a 100644 --- a/python/tests/unit/test_semantic_cache.py +++ b/python/tests/unit/test_semantic_cache.py @@ -304,6 +304,7 @@ def test_donkey_last_call_populated_via_transport() -> None: """The transport's _on_response lands the cache outcome on the context record.""" token = lastcall._last_call.set(None) try: + def handler(request: httpx.Request) -> httpx.Response: return _resp_from_fixture("responses.hit.headers.txt") diff --git a/python/tests/unit/test_sensitive_output.py b/python/tests/unit/test_sensitive_output.py index 161b5d65..1ac3532b 100644 --- a/python/tests/unit/test_sensitive_output.py +++ b/python/tests/unit/test_sensitive_output.py @@ -44,7 +44,11 @@ from donkey_kit.simulator.fixtures import parse_headers LLM_PROXY = ( - Path(__file__).resolve().parents[2] / "src" / "donkey_kit" / "simulator" / "_fixtures" + Path(__file__).resolve().parents[2] + / "src" + / "donkey_kit" + / "simulator" + / "_fixtures" / "anypoint" / "llm_proxy" ) diff --git a/python/tests/unit/test_simulate.py b/python/tests/unit/test_simulate.py index 2670b9f2..7a589748 100644 --- a/python/tests/unit/test_simulate.py +++ b/python/tests/unit/test_simulate.py @@ -76,9 +76,7 @@ def _sentinel(status: int = 299) -> httpx.MockTransport: @pytest.mark.parametrize("exc,shape", _ROUND_TRIP, ids=[e.__name__ for e, _ in _ROUND_TRIP]) -async def test_injected_refusal_replays_fixture_and_classifies_back( - exc: type, shape: str -) -> None: +async def test_injected_refusal_replays_fixture_and_classifies_back(exc: type, shape: str) -> None: # AC (4): the injected body is byte-identical to the fixture classify() is # tested against, so it lights up as exactly the requested typed refusal. donkey = _donkey() diff --git a/python/tests/unit/test_simulator_app.py b/python/tests/unit/test_simulator_app.py index 12e3dc75..81640e08 100644 --- a/python/tests/unit/test_simulator_app.py +++ b/python/tests/unit/test_simulator_app.py @@ -48,9 +48,7 @@ def _client() -> httpx.AsyncClient: """A fresh app (fresh x-token counter) behind an in-process transport.""" - return httpx.AsyncClient( - transport=httpx.ASGITransport(app=build_app()), base_url="http://sim" - ) + return httpx.AsyncClient(transport=httpx.ASGITransport(app=build_app()), base_url="http://sim") async def test_happy_path_replays_success_verbatim_and_stamps_honesty() -> None: @@ -303,9 +301,7 @@ async def test_chat_completions_sentinel_rejection_classifies( shape: str, exc: type[Exception] ) -> None: async with _client() as client: - resp = await client.post( - "/v1/chat/completions", json={"model": SIM_MODEL_PREFIX + shape} - ) + resp = await client.post("/v1/chat/completions", json={"model": SIM_MODEL_PREFIX + shape}) assert resp.headers[SIMULATOR_HEADER] == "true" assert resp.content == fx.load(shape).body assert isinstance(classify(resp), exc) @@ -330,12 +326,8 @@ async def test_stock_openai_client_reads_chat_completions(stream: bool) -> None: chunks = await client.chat.completions.create( model="gpt-5-mini", messages=messages, stream=True ) - text = "".join( - [c.choices[0].delta.content or "" async for c in chunks if c.choices] - ) + text = "".join([c.choices[0].delta.content or "" async for c in chunks if c.choices]) else: - completion = await client.chat.completions.create( - model="gpt-5-mini", messages=messages - ) + completion = await client.chat.completions.create(model="gpt-5-mini", messages=messages) text = completion.choices[0].message.content or "" assert text == "pong — how can I help?" diff --git a/python/tests/unit/test_simulator_fixtures.py b/python/tests/unit/test_simulator_fixtures.py index 87d7610e..1082648f 100644 --- a/python/tests/unit/test_simulator_fixtures.py +++ b/python/tests/unit/test_simulator_fixtures.py @@ -41,12 +41,7 @@ def test_parse_headers_matches_the_documented_rule() -> None: - text = ( - "HTTP/1.1 403 Forbidden\n" - "content-type: application/json\n" - "X-Foo: Bar\n" - "garbage-no-colon\n" - ) + text = "HTTP/1.1 403 Forbidden\ncontent-type: application/json\nX-Foo: Bar\ngarbage-no-colon\n" # HTTP line + colon-less line skipped; key lowercased; value stripped. assert fx.parse_headers(text) == {"content-type": "application/json", "x-foo": "Bar"} @@ -95,8 +90,7 @@ def test_injection_protection_loads_the_live_captured_body() -> None: # capture against ddk-injection-protection (instance 21200898). f = fx.load("injection-protection") assert f.body == ( - b'{"message":"Injection attack detected - ' - b"Rule: 'SQL Injection', Location: Body\"}" + b'{"message":"Injection attack detected - Rule: \'SQL Injection\', Location: Body"}' ) assert len(f.body) == 79 assert f.content_type == "application/json; charset=UTF-8" diff --git a/python/tests/unit/test_simulator_scenarios_app.py b/python/tests/unit/test_simulator_scenarios_app.py index 11ce5fb8..ee71351e 100644 --- a/python/tests/unit/test_simulator_scenarios_app.py +++ b/python/tests/unit/test_simulator_scenarios_app.py @@ -130,9 +130,7 @@ async def test_request_limit_and_budget_scenarios_compose() -> None: async def test_request_rate_limit_sentinel_serves_the_captured_shape() -> None: async with _client() as client: - resp = await client.post( - "/v1/responses", json={"model": "donkey-sim/request-rate-limit"} - ) + resp = await client.post("/v1/responses", json={"model": "donkey-sim/request-rate-limit"}) assert resp.status_code == 429 assert isinstance(classify(resp), RequestRateLimitExceeded) @@ -144,9 +142,7 @@ async def test_injection_wins_over_pii_and_budget_precedence() -> None: "budget:limit=1,window=60s,cost=1", "injection:on-pattern=inject", ) as client: - resp = await client.post( - "/v1/responses", json={"model": "gpt-5.1", "input": "inject this"} - ) + resp = await client.post("/v1/responses", json={"model": "gpt-5.1", "input": "inject this"}) assert resp.status_code == 400 assert resp.headers["x-injection-protection"] == "blocked" @@ -155,9 +151,7 @@ async def test_sentinel_shape_overrides_scenarios() -> None: # The donkey-sim/ model-id sentinel is an explicit force-this-shape override # and wins over ambient scenarios. async with _client("pii_block:every=1") as client: - resp = await client.post( - "/v1/responses", json={"model": "donkey-sim/upstream-5xx"} - ) + resp = await client.post("/v1/responses", json={"model": "donkey-sim/upstream-5xx"}) assert resp.status_code == 503 # the sentinel shape, not the pii 403 diff --git a/python/tests/unit/test_transport.py b/python/tests/unit/test_transport.py index 2b0e873d..d3fdda03 100644 --- a/python/tests/unit/test_transport.py +++ b/python/tests/unit/test_transport.py @@ -419,9 +419,7 @@ async def test_call_id_is_unique_per_call_and_distinct_from_run_id() -> None: seen: list[tuple[str, str]] = [] def handler(request: httpx.Request) -> httpx.Response: - seen.append( - (request.headers[CORRELATION_HEADER], request.headers[CALL_ID_HEADER]) - ) + seen.append((request.headers[CORRELATION_HEADER], request.headers[CALL_ID_HEADER])) return httpx.Response(200) async with _client(handler) as client: @@ -444,9 +442,7 @@ async def test_call_id_is_stable_across_retries() -> None: def handler(request: httpx.Request) -> httpx.Response: calls["n"] += 1 - seen.append( - (request.headers[CORRELATION_HEADER], request.headers[CALL_ID_HEADER]) - ) + seen.append((request.headers[CORRELATION_HEADER], request.headers[CALL_ID_HEADER])) return httpx.Response(503) if calls["n"] < 3 else httpx.Response(200) async with _client(handler, DonkeyConfig(max_retries=3)) as client: @@ -979,8 +975,11 @@ def handler(request: httpx.Request) -> httpx.Response: calls["n"] += 1 return httpx.Response( 429, - headers={"x-ratelimit-limit": "3", "x-ratelimit-remaining": "0", - "x-ratelimit-reset": "40000"}, + headers={ + "x-ratelimit-limit": "3", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "40000", + }, content=b'{"error":"Too Many Requests"}', ) @@ -1896,9 +1895,7 @@ def handler(request: httpx.Request) -> httpx.Response: cfg = _COST_CFG.with_overrides( cost=CostTags(team="support", project="triage-v2", env="prod", enduser_id="u-42") ) - async with DonkeyAsyncClient( - cfg, None, transport=httpx.MockTransport(handler) - ) as client: + async with DonkeyAsyncClient(cfg, None, transport=httpx.MockTransport(handler)) as client: await client.get("https://proxy/thing") assert seen["x-cost-team"] == "support" @@ -1915,9 +1912,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200) cfg = _COST_CFG.with_overrides(cost=CostTags(team="support")) - async with DonkeyAsyncClient( - cfg, None, transport=httpx.MockTransport(handler) - ) as client: + async with DonkeyAsyncClient(cfg, None, transport=httpx.MockTransport(handler)) as client: await client.get("https://proxy/thing") assert seen["x-cost-team"] == "support" @@ -1936,9 +1931,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200) cfg = _COST_CFG.with_overrides(cost=CostTags(team="support", env="prod")) - async with DonkeyAsyncClient( - cfg, None, transport=httpx.MockTransport(handler) - ) as client: + async with DonkeyAsyncClient(cfg, None, transport=httpx.MockTransport(handler)) as client: # A run overrides team + adds project; env falls back to the config tag. with run_scope("run-1", CostTags(team="triage-team", project="triage-v2")): await client.get("https://proxy/thing") @@ -2160,9 +2153,7 @@ async def test_async_send_forwards_capture_content_flag(monkeypatch, capture: bo def test_sync_send_forwards_capture_content_flag(monkeypatch, capture: bool) -> None: seen = _spy_capture_content(monkeypatch) cfg = DonkeyConfig(llm_proxy_url="https://proxy", telemetry_capture_content=capture) - client = DonkeyClient( - cfg, transport=httpx.MockTransport(lambda r: httpx.Response(200)) - ) + client = DonkeyClient(cfg, transport=httpx.MockTransport(lambda r: httpx.Response(200))) with client: client.post("https://proxy/chat", json={"model": "gpt-4o"}) # buffered client.post("https://proxy/chat", json={"model": "gpt-4o", "stream": True}) diff --git a/python/tests/unit/test_transport_both_clients.py b/python/tests/unit/test_transport_both_clients.py index f90c9402..78999dca 100644 --- a/python/tests/unit/test_transport_both_clients.py +++ b/python/tests/unit/test_transport_both_clients.py @@ -99,9 +99,7 @@ def _send( async def run() -> tuple[httpx.Response, list[PolicyViolation]]: async with _Async(cfg, None, transport=transport) as client: - response = await client.request( - method, _URL, json=body if method == "POST" else None - ) + response = await client.request(method, _URL, json=body if method == "POST" else None) return response, client.refusals return asyncio.run(run()) @@ -275,8 +273,9 @@ def handler(request: httpx.Request) -> httpx.Response: def test_both_clients_expose_their_governed_transport() -> None: inner = httpx.MockTransport(lambda r: httpx.Response(200)) - assert isinstance(DonkeyAsyncClient(_CFG, None, transport=inner).governed_transport, - GovernedTransport) + assert isinstance( + DonkeyAsyncClient(_CFG, None, transport=inner).governed_transport, GovernedTransport + ) sync = DonkeyClient(_CFG, transport=inner) assert isinstance(sync.governed_transport, GovernedSyncTransport) assert sync.governed_transport.inner is inner @@ -311,15 +310,17 @@ def _request(method: str = "POST", body: bytes = b'{"model": "m"}') -> httpx.Req def test_decide_retry_is_pure() -> None: req = _request() - assert decide_retry(_CFG, req, httpx.Response(200), attempt=0, attempts=4, - can_refresh=False) == Finish(None) - retry = decide_retry(_CFG, req, httpx.Response(503), attempt=0, attempts=4, - can_refresh=False) + assert decide_retry( + _CFG, req, httpx.Response(200), attempt=0, attempts=4, can_refresh=False + ) == Finish(None) + retry = decide_retry(_CFG, req, httpx.Response(503), attempt=0, attempts=4, can_refresh=False) assert isinstance(retry, Retry) - assert decide_retry(_CFG, req, httpx.Response(502), attempt=0, attempts=4, - can_refresh=False) == Finish("unsafe") - assert decide_retry(_CFG, req, httpx.Response(503), attempt=3, attempts=4, - can_refresh=False) == Finish("exhausted") + assert decide_retry( + _CFG, req, httpx.Response(502), attempt=0, attempts=4, can_refresh=False + ) == Finish("unsafe") + assert decide_retry( + _CFG, req, httpx.Response(503), attempt=3, attempts=4, can_refresh=False + ) == Finish("exhausted") # --- config ---------------------------------------------------------------------- diff --git a/python/tests/unit/test_transport_proxy_mounts.py b/python/tests/unit/test_transport_proxy_mounts.py index f4fd50a4..b32bde2d 100644 --- a/python/tests/unit/test_transport_proxy_mounts.py +++ b/python/tests/unit/test_transport_proxy_mounts.py @@ -85,9 +85,7 @@ async def test_simulate_stays_offline_under_proxy( assert no_sockets == [] -def test_sync_simulate_stays_offline_under_proxy( - dead_proxy: str, no_sockets: list[object] -) -> None: +def test_sync_simulate_stays_offline_under_proxy(dead_proxy: str, no_sockets: list[object]) -> None: donkey = Donkey(DonkeyConfig()) sync = donkey._sync_http_client() with donkey.simulate(PIIDetected): @@ -203,8 +201,9 @@ def close(self) -> None: # --- 3. fail closed ---------------------------------------------------------- -@pytest.mark.parametrize("make", [lambda: DonkeyClient(DonkeyConfig()), - lambda: DonkeyAsyncClient(DonkeyConfig(), None)]) +@pytest.mark.parametrize( + "make", [lambda: DonkeyClient(DonkeyConfig()), lambda: DonkeyAsyncClient(DonkeyConfig(), None)] +) def test_swap_refuses_when_a_mount_could_bypass_it(make: object) -> None: client = make() # type: ignore[operator] client._mounts = {URLPattern("all://"): httpx.MockTransport(lambda r: httpx.Response(200))} diff --git a/python/tests/unit/test_verification_blocked_surfaces.py b/python/tests/unit/test_verification_blocked_surfaces.py index b20c7702..94949c4d 100644 --- a/python/tests/unit/test_verification_blocked_surfaces.py +++ b/python/tests/unit/test_verification_blocked_surfaces.py @@ -33,15 +33,11 @@ ) -_SYNC_BLOCKED_SURFACES: tuple[ - tuple[str, Callable[[Donkey], object]], ... -] = ( +_SYNC_BLOCKED_SURFACES: tuple[tuple[str, Callable[[Donkey], object]], ...] = ( ("Donkey.tools.lock", lambda donkey: donkey.tools.lock()), ) -_ASYNC_BLOCKED_SURFACES: tuple[ - tuple[str, Callable[[Donkey], Awaitable[object]]], ... -] = ( +_ASYNC_BLOCKED_SURFACES: tuple[tuple[str, Callable[[Donkey], Awaitable[object]]], ...] = ( ("Donkey.tools.discover", lambda donkey: donkey.tools.discover()), ( "publish_if_changed", diff --git a/python/tests/unit/test_wire_names.py b/python/tests/unit/test_wire_names.py index 2973356e..aebefa36 100644 --- a/python/tests/unit/test_wire_names.py +++ b/python/tests/unit/test_wire_names.py @@ -83,6 +83,4 @@ def test_verify_binds_the_wire_strings() -> None: # _verify keeps the verification status; the strings themselves are _wire's. assert _verify.CORRELATION_ID_HEADER is _wire.CORRELATION_ID_HEADER assert _verify.LLM_PROXY_CLIENT_SECRET_HEADER is _wire.LLM_PROXY_CLIENT_SECRET_HEADER - assert ( - _verify.ATTRIBUTION_APP_HEADER.placeholder == _wire.ATTRIBUTION_APP_HEADER_PLACEHOLDER - ) + assert _verify.ATTRIBUTION_APP_HEADER.placeholder == _wire.ATTRIBUTION_APP_HEADER_PLACEHOLDER From 1b59e885a5a14b5115f5330a889aabb607d06a8a Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:02:16 +0200 Subject: [PATCH 03/34] ci(security): pin actions by SHA, add Dependabot, CODEOWNERS, audits (#1070) Closes #757 --- .gitattributes | 10 + .github/CODEOWNERS | 28 ++ .github/dependabot.yml | 57 ++++ .github/workflows/ci.yml | 256 ++++++++++++++---- .github/workflows/docs.yml | 25 +- .github/workflows/live-contract-check.yml | 6 +- .github/workflows/nightly-matrix.yml | 70 ++++- .github/workflows/pr-labels.yml | 6 +- .github/workflows/publish-pypi.yml | 12 +- .github/workflows/publish-testpypi.yml | 12 +- .github/workflows/secret-scan.yml | 3 +- CONTRIBUTING.md | 1 + docs/ci.md | 49 +++- .../tests/unit/test_workflow_supply_chain.py | 160 +++++++++++ 14 files changed, 594 insertions(+), 101 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 python/tests/unit/test_workflow_supply_chain.py diff --git a/.gitattributes b/.gitattributes index dfe07704..97025497 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,12 @@ # Auto detect text files and perform LF normalization * text=auto + +# Images are binary (#757): never normalise line endings in them, never show a +# textual diff, never attempt a textual merge. SVG stays text on purpose. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.avif binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..d47e574e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,28 @@ +# Code owners for the paths where a bad change reaches users or the release +# identity without touching SDK behaviour a reviewer would notice (#757). +# GitHub requests a review from these owners on any PR that changes a matching +# path; with "Require review from Code Owners" on in the develop and main +# branch rules, their approval is required to merge. +# +# The last matching pattern wins, so keep the specific paths below the general +# ones. Every owner must have write access to the repository, or GitHub +# ignores the line. + +# Workflows, Dependabot, release-note config and this file: CI runs these +# with repository tokens, and the publish workflows hold the PyPI identity. +/.github/ @tbolis-at-mulesoft @amirkhan-ak-sf + +# Package metadata: dependencies, extras, the build backend, tool config. +/python/pyproject.toml @tbolis-at-mulesoft @amirkhan-ak-sf + +# CI gate scripts (dependency allowlist, fixture scrubbing, verification +# claims, constraints lock, commit identities) and the fixture capture tool. +/python/scripts/ @tbolis-at-mulesoft @amirkhan-ak-sf + +# The framework-free core (§1.1), including the verification registry +# (core/_verify.py, §0.3). +/python/src/donkey_kit/core/ @tbolis-at-mulesoft @amirkhan-ak-sf + +# Captured gateway fixtures and their lock (§2): tests trust these as ground +# truth, and the scrubber keeps tenant data out of them. +/python/tests/fixtures/ @tbolis-at-mulesoft @amirkhan-ak-sf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0896e4f0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,57 @@ +# Dependabot version updates (#757). Security updates are a repository setting +# and stay on for every ecosystem regardless of this file. +# +# Every entry targets develop (the integration branch; main moves only by +# promotion), runs weekly, and groups its updates into one PR. `cooldown` holds +# a new release back for a week before proposing it, so a compromised release +# that is yanked within days never reaches a PR. +version: 2 +updates: + # Every `uses:` in .github/workflows is pinned to a full commit SHA with a + # `# vX.Y.Z` comment; Dependabot moves both together. + - package-ecosystem: github-actions + directory: / + target-branch: develop + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: ["*"] + commit-message: + prefix: ci(deps) + labels: [dependencies] + + # The docs site (website/). Minor and patch updates are grouped; a major + # (Next.js, Nextra, React) arrives as its own PR, since it usually needs code + # changes alongside the bump. + - package-ecosystem: npm + directory: /website + target-branch: develop + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + website-npm: + patterns: ["*"] + update-types: [minor, patch] + commit-message: + prefix: chore(deps) + labels: [dependencies] + + # Python: version updates are OFF (open-pull-requests-limit: 0). The runtime + # requirements in python/pyproject.toml are floors, never ceilings (§8.4, + # ADR 0007): raising a floor on a schedule would narrow what consumers can + # install for no reason. Version updates are meant to target only the + # dev/release toolchain lock that #763 adds; switch this entry to that lock's + # directory and lift the limit when it lands. Security updates are not + # affected by the limit. + - package-ecosystem: pip + directory: /python + target-branch: develop + schedule: + interval: weekly + open-pull-requests-limit: 0 + labels: [dependencies] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47aacc7e..7192dedc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,12 +54,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.11-${{ github.job }}-${{ hashFiles('python/constraints/base-only-py3.11.txt') }} @@ -68,7 +70,7 @@ jobs: # its own (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: python -m pip install --upgrade pip && pip install -e . --group dev -c constraints/base-only-py3.11.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -123,12 +125,14 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py${{ matrix.python-version }}-${{ github.job }}-${{ hashFiles('python/pyproject.toml') }} @@ -137,7 +141,7 @@ jobs: - name: pip resolves donkey-kit[all] run: timeout 300 pip install --dry-run ".[all]" - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -146,12 +150,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.11-${{ github.job }}-${{ hashFiles('python/constraints/typecheck-and-lint-py3.11.txt') }} @@ -159,7 +165,7 @@ jobs: # -c locks the resolve (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: python -m pip install --upgrade pip && pip install -e ".[llm,cli]" --group dev -c constraints/typecheck-and-lint-py3.11.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -206,8 +212,10 @@ jobs: # run: body directly (injection hardening). BASE_REF: ${{ github.base_ref }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: fetch the base branch's pyproject.toml @@ -233,11 +241,12 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false # Full history, so BASE_SHA..HEAD_SHA resolves. fetch-depth: 0 - - uses: actions/setup-python@v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: no placeholder git identities in the PR's commits (#1048) @@ -257,12 +266,14 @@ jobs: # run: body directly (injection hardening); also names this job's lock. PYTHON_VERSION: ${{ matrix.python-version }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py${{ matrix.python-version }}-${{ github.job }}-${{ hashFiles(format('python/constraints/test-py{0}.txt', matrix.python-version)) }} @@ -280,7 +291,7 @@ jobs: python -m pip install --upgrade pip && pip install -e ".[llm,cli,local,otel]" --group dev -c "constraints/test-py${PYTHON_VERSION}.txt" - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -365,19 +376,21 @@ jobs: # not silently skip (see tests/conftest.py). DONKEY_CONTRACT_EXTRA: anthropic steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.12-${{ github.job }}-${{ hashFiles('python/pyproject.toml') }} restore-keys: pip-${{ runner.os }}-py3.12-${{ github.job }}- - run: python -m pip install --upgrade pip && pip install -e ".[llm,anthropic]" --group dev "$ANTHROPIC_SPEC" - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -414,19 +427,21 @@ jobs: # silently skip (see tests/conftest.py). DONKEY_CONTRACT_EXTRA: adk steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.12-${{ github.job }}-${{ hashFiles('python/pyproject.toml') }} restore-keys: pip-${{ runner.os }}-py3.12-${{ github.job }}- - run: python -m pip install --upgrade pip && pip install -e ".[llm,adk]" --group dev "$ADK_SPEC" - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -451,12 +466,14 @@ jobs: # silently skip (see tests/conftest.py). DONKEY_CONTRACT_EXTRA: agent_framework steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.12-${{ github.job }}-${{ hashFiles('python/constraints/agent-framework-middleware-py3.12.txt') }} @@ -464,7 +481,7 @@ jobs: # -c locks the resolve (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: python -m pip install --upgrade pip && pip install -e ".[llm,agent_framework]" --group dev -c constraints/agent-framework-middleware-py3.12.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -488,12 +505,14 @@ jobs: # silently skip (see tests/conftest.py). DONKEY_CONTRACT_EXTRA: llamaindex steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.12-${{ github.job }}-${{ hashFiles('python/constraints/llamaindex-transport-py3.12.txt') }} @@ -501,7 +520,7 @@ jobs: # -c locks the resolve (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: python -m pip install --upgrade pip && pip install -e ".[llm,llamaindex]" --group dev -c constraints/llamaindex-transport-py3.12.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -524,12 +543,14 @@ jobs: # tests/conftest.py; a comma-separated list is how one job owns two). DONKEY_CONTRACT_EXTRA: "openai-agents,strands" steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.12-${{ github.job }}-${{ hashFiles('python/constraints/agents-strands-last-call-py3.12.txt') }} @@ -537,7 +558,7 @@ jobs: # -c locks the resolve (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: python -m pip install --upgrade pip && pip install -e ".[llm,openai-agents,strands]" --group dev -c constraints/agents-strands-last-call-py3.12.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -575,12 +596,14 @@ jobs: # run: body directly (injection hardening). DONKEY_CONTRACT_EXTRA: ${{ matrix.extra }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.12-${{ github.job }}-${{ hashFiles(format('python/constraints/adapter-contract-{0}-py3.12.txt', matrix.extra)) }} @@ -593,7 +616,7 @@ jobs: pip install -e ".[llm,local,$DONKEY_CONTRACT_EXTRA]" --group dev -c "constraints/adapter-contract-${DONKEY_CONTRACT_EXTRA}-py3.12.txt" - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -639,12 +662,14 @@ jobs: # workspace. `github.*` values are workflow-owned, not PR input. DONKEY_BENCHMARK_JSON: ${{ github.workspace }}/benchmark-result.json steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.11-${{ github.job }}-${{ hashFiles('python/constraints/benchmark-py3.11.txt') }} @@ -652,7 +677,7 @@ jobs: # -c locks the resolve (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: python -m pip install --upgrade pip && pip install -e ".[llm,cli,local,otel]" --group dev -c constraints/benchmark-py3.11.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -660,7 +685,7 @@ jobs: run: pytest -q -m benchmark -s - name: Upload the benchmark result for trending (#753) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: benchmark-result path: ${{ env.DONKEY_BENCHMARK_JSON }} @@ -690,12 +715,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.11-${{ github.job }}-${{ hashFiles('python/constraints/quickstart-py3.11.txt') }} @@ -703,7 +730,7 @@ jobs: # -c locks the resolve (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: pip install -e ".[llm,local,otel]" -c constraints/quickstart-py3.11.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -724,12 +751,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - id: pip-cache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: pip-${{ runner.os }}-py3.11-${{ github.job }}-${{ hashFiles('python/constraints/langgraph-demo-py3.11.txt') }} @@ -737,7 +766,7 @@ jobs: # -c locks the resolve (ADR 0007 rule 3, #948); refresh with scripts/compile_constraints.py. - run: python -m pip install --upgrade pip && pip install -e ".[llm,langgraph,local,otel]" --group dev -c constraints/langgraph-demo-py3.11.txt - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ steps.pip-cache.outputs.cache-primary-key }} @@ -771,8 +800,10 @@ jobs: run: working-directory: website steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20 - name: Unit-test the analytics beacon gate and the reader-facing link ref @@ -788,6 +819,110 @@ jobs: fi echo "llms artifacts are up to date." + pip-audit: + # #757: known vulnerabilities in what a consumer installs today, blocking. + # The install is resolved fresh (no -c): the question is what + # `pip install donkey-kit` / `donkey-kit[all]` pulls in now, not what the + # PR lock pinned (ADR 0007 rule 3 governs the lock; this job is an audit, + # like all-extra-resolves is a resolve check). pip only resolves (--dry-run + # --report), so nothing is installed; the pinned set, minus donkey-kit + # itself, goes to pip-audit with --no-deps. nightly-matrix.yml repeats this + # so a new advisory reaches a human between PRs. + name: pip-audit (${{ matrix.target }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: base + spec: "." + - target: all + spec: ".[all]" + env: + # Bind the matrix value to an env var so it is never interpolated into a + # run: body directly (injection hardening). + SPEC: ${{ matrix.spec }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: resolve the install into an exact pin list + run: | + python -m pip install --upgrade pip + timeout 300 pip install --dry-run --ignore-installed --quiet \ + --report "$RUNNER_TEMP/resolve.json" "$SPEC" + jq -r '.install[] | select(.metadata.name != "donkey-kit") + | "\(.metadata.name)==\(.metadata.version)"' \ + "$RUNNER_TEMP/resolve.json" > "$RUNNER_TEMP/pip-audit-requirements.txt" + cat "$RUNNER_TEMP/pip-audit-requirements.txt" + - uses: pypa/gh-action-pip-audit@1220774d901786e6f652ae159f7b6bc8fea6d266 # v1.1.0 + with: + inputs: ${{ runner.temp }}/pip-audit-requirements.txt + no-deps: true + + dependency-review: + # #757 (from #233): flag a PR that adds a dependency with a known + # vulnerability, or under a licence incompatible with Apache-2.0, at review + # time. Reads the dependency graph diff between base and head, so it needs + # no checkout. PRs only: there is no diff to review on a push. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + deny-licenses: GPL-2.0-only, GPL-2.0-or-later, GPL-3.0-only, GPL-3.0-or-later, AGPL-3.0-only, AGPL-3.0-or-later + + workflow-lint: + # #757: actionlint (workflow syntax, expressions, shellcheck over run: + # bodies) and zizmor (Actions security audits: unpinned or impostor uses:, + # excessive permissions, template injection, persisted credentials) over + # .github/. Both tools are fetched at a pinned version and verified by + # sha256 before they run, the same way secret-scan.yml fetches gitleaks. + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: . + env: + ACTIONLINT_VERSION: "1.7.12" + # sha256 of actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz, from the release's checksums.txt + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + ZIZMOR_VERSION: "1.30.1" + # sha256 of zizmor-${ZIZMOR_VERSION}-py3-none-manylinux_2_28_x86_64.whl, from PyPI + ZIZMOR_SHA256: "eee12266b793cb87ad4a7e3af2e72404f8a63e3de5eb099b80bf7b1cfd232a8e" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: install actionlint + working-directory: ${{ runner.temp }} + run: | + tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSLo "$tarball" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${tarball}" + echo "${ACTIONLINT_SHA256} ${tarball}" | sha256sum -c - + tar xzf "$tarball" actionlint + sudo install actionlint /usr/local/bin/actionlint + - name: actionlint + run: actionlint -color + - name: install zizmor + run: | + echo "zizmor==${ZIZMOR_VERSION} --hash=sha256:${ZIZMOR_SHA256}" > "$RUNNER_TEMP/zizmor.txt" + python -m pip install --require-hashes --no-deps -r "$RUNNER_TEMP/zizmor.txt" + - name: zizmor + env: + # Read-only token for the online audits (impostor commits, known-vulnerable actions). + GH_TOKEN: ${{ github.token }} + run: zizmor --format=github .github/ + ci-ok: # #755: the one required status check. Branch protection requires only this # job, so renaming a job or changing a matrix never silently drops a @@ -814,6 +949,9 @@ jobs: - quickstart - langgraph-demo - docs-llms-drift + - pip-audit + - dependency-review + - workflow-lint runs-on: ubuntu-latest timeout-minutes: 10 defaults: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 75766407..6fef3991 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,11 +19,11 @@ on: - cron: '17 */6 * * *' workflow_dispatch: -# Least privilege + the OIDC token deploy-pages needs to publish. +# Least privilege (#757): the workflow default is read-only. Only the deploy job +# gets `pages: write` and the OIDC token deploy-pages needs to publish; the +# build job, which runs third-party npm code, never holds either. permissions: contents: read - pages: write - id-token: write # Serialize publishes; never cancel a deploy that is mid-flight. concurrency: @@ -38,12 +38,15 @@ jobs: build: runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read steps: # Scheduled runs start from the default branch (develop); always publish main. - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event_name == 'schedule' && 'main' || github.ref }} - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20 cache: npm @@ -58,8 +61,7 @@ jobs: NEXT_PUBLIC_CF_WEB_ANALYTICS_TOKEN: ${{ vars.CF_WEB_ANALYTICS_TOKEN }} - name: Disable Jekyll so Pages serves Next's _next/ directory run: touch out/.nojekyll - - uses: actions/configure-pages@v6 - - uses: actions/upload-pages-artifact@v5 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: website/out @@ -67,9 +69,16 @@ jobs: needs: build runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: + # configure-pages reads the Pages site config, so it runs here, next to + # the deploy, rather than in the unprivileged build job. + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/live-contract-check.yml b/.github/workflows/live-contract-check.yml index d0fb62ad..f7d83a50 100644 --- a/.github/workflows/live-contract-check.yml +++ b/.github/workflows/live-contract-check.yml @@ -67,8 +67,10 @@ jobs: exit 1 fi - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/nightly-matrix.yml b/.github/workflows/nightly-matrix.yml index 824a4973..82c32b99 100644 --- a/.github/workflows/nightly-matrix.yml +++ b/.github/workflows/nightly-matrix.yml @@ -1,6 +1,6 @@ # Nightly framework matrix (§8.4, BG §1.8). # -# Purpose: two different nightly checks, in two different jobs below. +# Purpose: three different nightly checks, in three different jobs below. # # `matrix` — catches upstream framework churn for the # CONFORMANCE-TESTED adapter(s) (today: LangGraph only). @@ -8,6 +8,8 @@ # fresh, unconstrained resolve (#748; see that job's own # comment for why it exists alongside ci.yml's # constraint-locked `adapter-contract` legs). +# `pip-audit` — known vulnerabilities in a fresh base / `[all]` resolve +# (#757). # # `matrix` scope (#197): the roster is "one deep, seven shallow" — only # LangGraph is conformance-tested, so it is the only framework in `matrix` @@ -71,8 +73,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install (dev + llm, no framework) @@ -93,8 +97,10 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - run: python -m pip install --upgrade pip @@ -131,8 +137,10 @@ jobs: FRAMEWORK: ${{ matrix.framework }} RUN_LIVE: ${{ inputs.live }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -237,8 +245,10 @@ jobs: # key) to decide which importorskip calls must fail instead of skip. DONKEY_CONTRACT_EXTRA: ${{ matrix.extra }} steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -258,6 +268,46 @@ jobs: - name: Strict example smoke (non-zero on a missing framework; governed calls via the conformance kit) run: timeout 120 python scripts/smoke_example.py "$FRAMEWORK" + # Known vulnerabilities in a fresh `donkey-kit` / `donkey-kit[all]` resolve + # (#757). Floors, never ceilings (§8.4) means a vulnerable upstream release + # reaches consumers without a PR here, so it shows up here first; a finding + # fails this job, and the `alert` job then opens or updates the one + # nightly-failure tracking issue. Same steps as ci.yml's pip-audit job. + pip-audit: + name: pip-audit (${{ matrix.target }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: base + spec: "." + - target: all + spec: ".[all]" + env: + SPEC: ${{ matrix.spec }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: resolve the install into an exact pin list + run: | + python -m pip install --upgrade pip + timeout 300 pip install --dry-run --ignore-installed --quiet \ + --report "$RUNNER_TEMP/resolve.json" "$SPEC" + jq -r '.install[] | select(.metadata.name != "donkey-kit") + | "\(.metadata.name)==\(.metadata.version)"' \ + "$RUNNER_TEMP/resolve.json" > "$RUNNER_TEMP/pip-audit-requirements.txt" + cat "$RUNNER_TEMP/pip-audit-requirements.txt" + - uses: pypa/gh-action-pip-audit@1220774d901786e6f652ae159f7b6bc8fea6d266 # v1.1.0 + with: + inputs: ${{ runner.temp }}/pip-audit-requirements.txt + no-deps: true + alert: # #755: the dependency policy (floors, never ceilings, §8.4) relies on this # run to catch upstream breakage, so a red run must reach a human. `needs:` @@ -265,7 +315,7 @@ jobs: # asserts it); `failure()` is true when any of them failed. Open-or-update # one tracking issue, the same pattern as live-contract-check.yml (#753). if: failure() - needs: [gates, all-extra-resolves, matrix, framework-legs] + needs: [gates, all-extra-resolves, matrix, framework-legs, pip-audit] runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml index f9a16132..e90885dc 100644 --- a/.github/workflows/pr-labels.yml +++ b/.github/workflows/pr-labels.yml @@ -6,8 +6,10 @@ name: PR labels # # pull_request_target so fork PRs get a write token too. Safe because this # job never checks out or runs PR code: it only reads issue metadata via gh. +# That is also why zizmor's dangerous-triggers audit is waived on the trigger +# below (#757): the risk it flags is running untrusted PR code with this token. on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] types: [opened, edited, reopened, ready_for_review, closed] permissions: @@ -28,6 +30,8 @@ jobs: steps: - name: copy linked-issue type labels onto the PR run: | + # The GraphQL $variables are meant literally, not shell-expanded. + # shellcheck disable=SC2016 labels="$(gh api graphql \ -F owner="${REPO%/*}" -F name="${REPO#*/}" -F pr="$PR" \ -f query='query($owner: String!, $name: String!, $pr: Int!) { diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3246ec63..9aa50e54 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -25,8 +25,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: Build @@ -56,7 +58,7 @@ jobs: exit 1 fi echo "Requires-Dist carries only >= floors." - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: python/dist/ @@ -74,10 +76,10 @@ jobs: permissions: id-token: write # OIDC token for Trusted Publishing; no stored secret. steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist/ diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 7fa7cd36..4325a7ba 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -24,8 +24,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: Build @@ -54,7 +56,7 @@ jobs: exit 1 fi echo "Requires-Dist carries only >= floors." - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: python/dist/ @@ -71,11 +73,11 @@ jobs: permissions: id-token: write # OIDC token for Trusted Publishing; no stored secret. steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ packages-dir: dist/ diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index c46e2b4f..eaa6d71d 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -24,8 +24,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false # Full history, so a secret added and then deleted within a PR is still caught. fetch-depth: 0 # The binary, not gitleaks-action: the action needs a paid licence for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8bf4bab8..d9ab4384 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -853,6 +853,7 @@ rule, add its row; a rule that nothing can check is a review note, not a rule. | Citation habit | Review-only: no tool can tell whether a comment should cite a spec section | review | | Trademark-descriptive language | Review-only | review | | Never commit secrets | `.gitignore` entries; the committed-file secret warning in `tests/unit/test_config_endpoint_trust.py`; gitleaks (`.gitleaks.toml`); GitHub push protection | `pytest`, `secret-scan`, pre-commit hook, `git push` | +| Hardened Actions supply chain (#757) | `tests/unit/test_workflow_supply_chain.py` (every `uses:` on a full SHA with a `# vX.Y.Z` comment, no checkout keeps the token, OIDC and Pages writes on the publish and deploy jobs only, Dependabot, CODEOWNERS and binary-image rules present); actionlint and zizmor over `.github/`; `pip-audit` on a fresh base and `.[all]` resolve; `dependency-review` (PRs) | `pytest`; `workflow-lint`, `pip-audit`, `dependency-review` | | No tenant identifiers in tracked files or built dists (#821) | `scripts/scrub_fixtures.py --check` (UUIDs outside `ALLOWED_UUIDS`, platform hosts, provider account headers) | `typecheck-and-lint`, `base-only` (wheel), publish workflows (wheel + sdist) | | No dead parameters or stale suppressions | ruff `ARG`, `RUF100`, `TRY203` | `ruff check .` | | Logging convention (#717) | ruff `BLE`, `LOG`, `G`; `tests/unit/test_logging.py` (DEBUG records for retry and 401 refresh; no header value in any record) | `ruff check .`, `pytest` | diff --git a/docs/ci.md b/docs/ci.md index 2a718c04..6b07b91e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -78,6 +78,11 @@ The jobs, grouped by what they protect: (BG §1.6) and trends it across runs. `quickstart` and `langgraph-demo` run the documented examples against the local simulator. `docs-llms-drift` regenerates `llms.txt` and fails on any diff. +- **Supply chain.** `pip-audit` audits a fresh resolve of the base install + and of `.[all]` for known vulnerabilities, blocking (#757). + `dependency-review` flags a PR that adds a vulnerable dependency or one + under a GPL/AGPL licence, and runs on PRs only. `workflow-lint` runs + actionlint and zizmor over `.github/` (convention 7). - **The gate itself.** `ci-ok` needs every other job and fails unless each one succeeded or was skipped. It is the one required status check (convention 2). @@ -86,7 +91,9 @@ The jobs, grouped by what they protect: The nightly run resolves fresh against the newest upstream releases, with no constraints (ADR 0007 rule 4). `matrix` re-verifies the conformance-tested adapter (LangGraph) against §8 signatures, and `framework-legs` runs every -`ADAPTERS` entry (#748). A live proxy round-trip is available only through +`ADAPTERS` entry (#748). `pip-audit` repeats the PR job's vulnerability audit, +so an advisory published between PRs still reaches a human (#757). A live +proxy round-trip is available only through `workflow_dispatch` with `live: true`. A red nightly is an upstream signal: fix it by adapting the code or by moving the lock, never by adding a ceiling (`§8.4`). Any failed run, scheduled or manual, opens one issue labelled @@ -343,15 +350,32 @@ serialise their runs and never cancel one mid-flight. Every workflow declares `permissions:` at the top, and elevated permissions go only on the job that needs them. The publish workflows grant `id-token: write` -to the publish job alone. Values from `${{ }}` are bound to `env:` and used as -quoted shell variables, never interpolated into a `run:` body. Still open: - -- Pin every `uses:` to a full commit SHA with a version comment, starting with - `pypa/gh-action-pypi-publish` (still on the `release/v1` branch ref). -- Move `docs.yml`'s `pages: write` and `id-token: write` from the workflow to - the deploy job. -- Add a `.github/dependabot.yml` for actions, pip and npm, a `CODEOWNERS` file - covering `.github/**` and the other sensitive paths, and workflow linting. +to the publish job alone, and `docs.yml` grants `pages: write` and +`id-token: write` to its deploy job alone; its build job, which runs +third-party npm code, has `contents: read` only (#757). Values from `${{ }}` +are bound to `env:` and used as quoted shell variables, never interpolated +into a `run:` body. In place since #757: + +- Every `uses:` is pinned to a full commit SHA with a `# vX.Y.Z` comment, and + every checkout sets `persist-credentials: false`. +- `.github/dependabot.yml` sends weekly, grouped version updates for actions + and for `website/` npm, all targeting `develop`, with a 7-day cooldown on new + releases. pip version updates stay off until the dev/release lock from #763 + exists: the floors in `pyproject.toml` are never raised on a schedule + (`§8.4`). Security updates are on for every ecosystem. +- `.github/CODEOWNERS` covers `.github/`, `python/pyproject.toml`, + `python/scripts/`, `python/src/donkey_kit/core/` and `python/tests/fixtures/`. + The owners' review is required once the branch rules turn on + "Require review from Code Owners". +- `ci.yml`'s `workflow-lint` runs actionlint and zizmor over `.github/`. +- `tests/unit/test_workflow_supply_chain.py` fails on an unpinned `uses:`, a + checkout that keeps the token, an OIDC or Pages grant outside the publish + and deploy jobs, or a missing Dependabot, CODEOWNERS or binary-image rule. + +Still open: + +- Pin the release build tools (`pip install build twine` in the publish + workflows) with the toolchain lock (#763). - Pin manual docs deploys to `main` (#771). ### 8. The site is built on PRs, on a supported Node. **Target** (#759) @@ -403,6 +427,11 @@ The same rules apply in every Donkey-Development-Kit repo: `_COMBOS` entry in `compile_constraints.py` and a place in `ci-ok`'s `needs:`. A new job in `nightly-matrix.yml` goes in `alert`'s `needs:`. - A new scheduled workflow ships with its failure-alert step (convention 4). +- A new `uses:` is pinned to the full commit SHA of a release, with the + release's `# vX.Y.Z` as a trailing comment, and a new checkout sets + `persist-credentials: false` (convention 7). Run `actionlint` and + `zizmor .github/` locally before pushing a workflow change; `workflow-lint` + runs both on the PR. - A change to which tests run where updates the test-surface table in [`CONTRIBUTING.md` §2](../CONTRIBUTING.md#2-testing-strategy). - A change to the dependency policy itself needs an ADR (see diff --git a/python/tests/unit/test_workflow_supply_chain.py b/python/tests/unit/test_workflow_supply_chain.py new file mode 100644 index 00000000..14b86e8f --- /dev/null +++ b/python/tests/unit/test_workflow_supply_chain.py @@ -0,0 +1,160 @@ +"""The Actions supply chain stays hardened (#757, docs/ci.md convention 7). + +A tag or branch ref can be moved to a different commit after review, so a +compromised or re-tagged action would run with whatever the job holds: the PyPI +publishing identity in the publish jobs, Pages write in the docs deploy. These +checks keep the hardening from eroding one workflow edit at a time: + +- every ``uses:`` is a full commit SHA with a ``# vX.Y.Z`` comment (Dependabot + moves both); +- no checkout leaves the job token in ``.git/config``; +- ``id-token: write`` and ``pages: write`` are granted per job, only to the + jobs that publish; +- Dependabot, CODEOWNERS and the binary-image attributes exist and cover what + the issue asked for. + +``workflow-lint`` in ci.yml runs actionlint and zizmor over the same files; this +suite is the offline, framework-free half that the base-only job also runs. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest +import yaml + +_REPO = Path(__file__).resolve().parents[3] +_GITHUB = _REPO / ".github" +_WORKFLOWS = _GITHUB / "workflows" +_FILES = sorted(_WORKFLOWS.glob("*.yml")) if _WORKFLOWS.is_dir() else [] + +pytestmark = pytest.mark.skipif(not _FILES, reason="not a repo checkout (no .github/workflows)") + +_USES = re.compile(r"^\s*(?:-\s+)?uses:\s*(?P\S+)(?P.*)$") +_PINNED = re.compile(r"^[\w.-]+/[\w.-]+(?:/[\w./-]+)?@[0-9a-f]{40}$") +_VERSION_COMMENT = re.compile(r"^\s+#\s+v\d+\.\d+\.\d+\s*$") + +#: (workflow file, job) pairs allowed to mint an OIDC token. The two publish +#: jobs use it for PyPI Trusted Publishing, the docs deploy for deploy-pages. +_ID_TOKEN_JOBS = { + ("publish-pypi.yml", "publish-pypi"), + ("publish-testpypi.yml", "publish-testpypi"), + ("docs.yml", "deploy"), +} + + +def _load(path: Path) -> dict[Any, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + return data + + +@pytest.fixture(params=_FILES, ids=[f.name for f in _FILES]) +def workflow(request: pytest.FixtureRequest) -> Path: + path = request.param + assert isinstance(path, Path) + return path + + +def test_every_action_is_pinned_to_a_full_sha_with_a_version_comment(workflow: Path) -> None: + bad = [] + for number, line in enumerate(workflow.read_text(encoding="utf-8").splitlines(), 1): + match = _USES.match(line) + if not match or match["ref"].startswith("./"): + continue + if not _PINNED.match(match["ref"]) or not _VERSION_COMMENT.match(match["rest"]): + bad.append(f"{workflow.name}:{number}: {line.strip()}") + assert not bad, "uses: not pinned as @<40-hex sha> # vX.Y.Z:\n" + "\n".join(bad) + + +def test_no_checkout_persists_the_job_token(workflow: Path) -> None: + persisted = [ + name + for name, job in _load(workflow)["jobs"].items() + for step in job.get("steps", []) + if str(step.get("uses", "")).startswith("actions/checkout@") + and step.get("with", {}).get("persist-credentials") is not False + ] + assert not persisted, f"{workflow.name}: checkout without persist-credentials: false" + + +def test_publish_permissions_are_granted_per_job_only(workflow: Path) -> None: + data = _load(workflow) + top = data.get("permissions", {}) + assert isinstance(top, dict), f"{workflow.name}: top-level permissions must be a mapping" + for scope in ("id-token", "pages"): + assert scope not in top, f"{workflow.name}: {scope} granted to the whole workflow" + for name, job in data["jobs"].items(): + perms = job.get("permissions", {}) + if perms.get("id-token") == "write": + assert (workflow.name, name) in _ID_TOKEN_JOBS, ( + f"{workflow.name}: job {name!r} mints an OIDC token but is not a publish job" + ) + if perms.get("pages") == "write": + assert (workflow.name, name) == ("docs.yml", "deploy"), ( + f"{workflow.name}: job {name!r} has pages: write" + ) + + +def test_docs_build_job_cannot_publish() -> None: + # The build job runs third-party npm code (`npm ci`, `next build`). + jobs = _load(_WORKFLOWS / "docs.yml")["jobs"] + assert jobs["build"]["permissions"] == {"contents": "read"} + assert jobs["deploy"]["permissions"]["pages"] == "write" + assert jobs["deploy"]["permissions"]["id-token"] == "write" + + +def test_dependabot_covers_actions_npm_and_pip() -> None: + config = _load(_GITHUB / "dependabot.yml") + updates = {(u["package-ecosystem"], u["directory"]): u for u in config["updates"]} + assert set(updates) == { + ("github-actions", "/"), + ("npm", "/website"), + ("pip", "/python"), + } + for (ecosystem, _), update in updates.items(): + assert update["target-branch"] == "develop", ecosystem + assert update["schedule"]["interval"] == "weekly", ecosystem + for key in (("github-actions", "/"), ("npm", "/website")): + assert updates[key].get("groups"), f"{key[0]} updates are not grouped" + # python/pyproject.toml holds floors, never ceilings (§8.4): a scheduled bump + # there would raise a floor. Version updates belong to the dev/release lock + # (#763), so pointed at /python they stay off. + assert updates[("pip", "/python")]["open-pull-requests-limit"] == 0 + + +def _codeowner_rules() -> dict[str, list[str]]: + rules: dict[str, list[str]] = {} + for line in (_GITHUB / "CODEOWNERS").read_text(encoding="utf-8").splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + pattern, *owners = line.split() + rules[pattern] = owners + return rules + + +def test_codeowners_cover_the_sensitive_paths() -> None: + rules = _codeowner_rules() + for path in ( + "/.github/", + "/python/pyproject.toml", + "/python/scripts/", + "/python/src/donkey_kit/core/", + "/python/tests/fixtures/", + ): + assert rules.get(path), f"CODEOWNERS has no owner for {path}" + assert (_REPO / path.strip("/")).exists(), f"CODEOWNERS path {path} does not exist" + assert all(o.startswith("@") for owners in rules.values() for o in owners) + + +def test_images_are_marked_binary() -> None: + rules = { + tuple(line.split()[:2]) + for line in (_REPO / ".gitattributes").read_text(encoding="utf-8").splitlines() + if line.strip() and not line.startswith("#") + } + for ext in ("png", "jpg", "jpeg", "gif", "ico", "webp"): + assert (f"*.{ext}", "binary") in rules, f"*.{ext} is not marked binary" From 39b087d9f3520512f35f04f2ce84baadd4ca422f Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:18:33 +0200 Subject: [PATCH 04/34] feat(refusals): bridge typed refusals through ADK model() (#1077) Closes #969 --- ARCHITECTURE.md | 16 +- README.md | 2 +- docs/adr/0002-typed-refusal-bridge.md | 32 +- docs/verified-apis.md | 2 +- python/README.md | 2 +- python/pyproject.toml | 4 +- python/src/donkey_kit/core/refusals.py | 101 +++++- .../src/donkey_kit/core/transport/pipeline.py | 43 ++- python/src/donkey_kit/integrations/adk.py | 60 +++- python/tests/conformance/suite.py | 28 +- .../test_readme_exemptions_table.py | 1 - .../conformance/test_typed_refusal_bridge.py | 69 ++-- .../tests/unit/test_adapter_capabilities.py | 10 +- python/tests/unit/test_adapter_ergonomics.py | 3 + python/tests/unit/test_refusal_capture.py | 313 ++++++++++++++++++ website/content/errors.mdx | 7 +- website/content/examples/adk.mdx | 37 ++- website/content/examples/index.mdx | 2 +- website/content/frameworks/adk.mdx | 20 +- website/public/errors.md | 7 +- website/public/examples.md | 2 +- website/public/examples/adk.md | 35 +- website/public/frameworks/adk.md | 20 +- website/public/llms-full.txt | 64 ++-- website/public/llms.txt | 2 +- 25 files changed, 754 insertions(+), 128 deletions(-) create mode 100644 python/tests/unit/test_refusal_capture.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1a52fd0a..83319636 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -571,6 +571,17 @@ recovers the typed error by duck type, without importing any framework. - stops at a link that carries no HTTP request or response, such as the user's own re-wrap or a bug in a handler, and leaves the error alone. +Right after the `DonkeyError` check, a link tagged by a refusal capture returns its tag (#969). LiteLLM +re-raises a refusal around a response it rebuilt, with no cause and no gateway +headers, so nothing on the error leads back to our transport. For such a +framework the adapter opens `capture_refusals()` around one framework call (ADK's +`model()` does it in the `LiteLLMClient` it passes as `llm_client`). The governed +pipeline records each send's typed outcome there: the classified error of a +non-2xx final response, the `ModelSubstituted` it raises, the +`GatewayUnavailable` or lifecycle `ConfigError` of a failed send, and `None` for +a success. The adapter tags the error the call raised with the last outcome and +re-raises it unchanged, so the tag only ever holds what our transport saw. + A framework wrapper that carries no HTTP shape is seen through by an optional per-adapter translator, named by `AdapterSpec.refusal_translator` in `integrations/__init__.py`. It is loaded only once that framework has been @@ -628,8 +639,9 @@ through the shared client (`http_client` / `async_http_client`, an exemptions are gone (#740). Only CrewAI remains: its native OpenAI provider builds the sync `OpenAI` and the `AsyncOpenAI` from one `client_params` dict, and with an interceptor set it replaces `http_client` with its own `httpx` -client, so the SDK's async client cannot be injected. ADK's `model()` keeps one -exemption, `typed_refusal_bridged` (#724). `tests/unit/test_adapter_roster.py` +client, so the SDK's async client cannot be injected. ADK's `model()` had one +more exemption, `typed_refusal_bridged` (#724), until its refusal capture +bridged it (#969). `tests/unit/test_adapter_roster.py` keeps the table and each adapter's `AdapterCapabilities` equal: `gateway_identity_observed` is exempt exactly when `observes_last_call` is false, `typed_refusal_bridged` when `typed_refusals` is false, and `jwt_token_refreshed` diff --git a/README.md b/README.md index 269f0929..7bd1f533 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,6 @@ legitimately cannot satisfy a scenario, the reason is asserted in code | CrewAI | gateway identity observed | For the same reason, no response reaches the SDK's `_on_response` hook. When every resolved adapter is non-observing, `donkey.last_call` reports `UNAVAILABLE` and names them in `surface`. | | CrewAI | JWT refreshed per send | CrewAI's native OpenAI provider owns the transport and builds its own clients, so the rotating JWT the SDK adds per send never reaches its requests. `donkey.crewai.llm()` and `connection_kwargs()` raise `ConfigError` in `jwt` mode; use client-id auth with CrewAI. ADK's `model()` and `gemini()`, LlamaIndex and Microsoft Agent Framework send through the SDK's client and carry the rotating JWT on async calls ([`jwt` mode](https://docs.donkey-kit.dev/reference/configuration#jwt--model-wallet-auth-mode)). | | CrewAI | budget refusal not retried | CrewAI wraps every LLM call in its own rate-limit retry (3 attempts) and treats any `429` as a rate limit, so a `TokenBudgetExceeded` or `RequestRateLimitExceeded` refusal is sent 3 times. CrewAI has no setting to turn it off; the OpenAI client underneath has `max_retries=0`. Every other adapter sends a budget refusal once (#734). | -| ADK `model()`, CrewAI | typed refusal bridged | Each raises its own error for a refusal, for a different reason, so `donkey.run()` and `typed_refusals()` cannot tell a gateway refusal from any other failure there and pass those errors through (#724). For ADK's `model()`, LiteLLM sends through the SDK's transport (#946) but re-raises a refusal as its own exception around a response it rebuilt, which carries no sign the SDK sent it. For CrewAI, the native OpenAI provider owns the transport, so its errors come from a client the SDK never saw. ADK's `gemini()` is handed the SDK's httpx client and is bridged (conformance-tested in #955). | +| CrewAI | typed refusal bridged | CrewAI's native OpenAI provider owns the transport, so its errors come from a client the SDK never saw, and `donkey.run()` and `typed_refusals()` cannot tell a gateway refusal from any other failure there and pass those errors through (#724). ADK's `gemini()` and `model()` send through the SDK's transport and are bridged (conformance-tested in #955 and #969). LiteLLM, under `model()`, re-raises a refusal as its own exception, and the adapter's `llm_client` marks that exception with the refusal the SDK's transport received. | Each exemption matches what the adapter reports in `donkey..capabilities()` (a frozen `AdapterCapabilities` per factory), and a unit test keeps the two in step (#726). diff --git a/docs/adr/0002-typed-refusal-bridge.md b/docs/adr/0002-typed-refusal-bridge.md index 1a5457f5..733ac0fd 100644 --- a/docs/adr/0002-typed-refusal-bridge.md +++ b/docs/adr/0002-typed-refusal-bridge.md @@ -1,7 +1,8 @@ # ADR 0002: One framework-agnostic typed-refusal bridge, in core - **Status:** Accepted -- **Amended by:** #950 (2026-10-05), exception groups. +- **Amended by:** #950 (2026-10-05), exception groups; #969 (2026-10-10), + refusal capture. - **Date:** 2026-10-03 - **Issue:** #724 (part of #707). Recorded under the ADR process from #731. @@ -149,3 +150,32 @@ decision 1 never looked inside one. The bridge now does, by this rule: Alternatives considered: always keeping the group, even a uniform one, would make `except PIIDetected` miss every task-group refusal. Raising the first refusal of a mixed group would hide the user's own bugs in sibling tasks. + +## Amendment (2026-10-10, #969) + +LiteLLM, under ADK's `LiteLlm` (`donkey.adk.model()`), sends through our +transport (#946) but raises a refusal as its own exception around a response +it rebuilt: no gateway headers, no `request.extensions`, and no `__cause__`. +Decision 6 rightly refuses to classify it, so the factory was exempt. The +bridge now has one more rule, ahead of the translators: + +1. **A per-call capture records what the transport saw.** + `core/refusals.py` `capture_refusals()` opens a `RefusalCapture` in a + context variable. While one is open, the governed pipeline records each + send's typed outcome in it: the classified error of a non-2xx final + response, the `ModelSubstituted` it raises, the `GatewayUnavailable` or + lifecycle `ConfigError` of a failed send, and `None` for a success, so a + retried call that succeeds clears an earlier failure. +2. **The adapter tags the framework's error and re-raises it unchanged.** + The adapter opens the capture around exactly one framework call and, if the + call raises, tags that exception with the last recorded outcome. Outside + `donkey.run()`, `@governed` and `typed_refusals()`, the caller still sees + the framework's error. +3. **`translate()` returns a link's tag.** A tagged link stands for its typed + error, as a `DonkeyError` link does. Decision 6 still holds: a tag only ever + holds an error the SDK's own transport recorded. + +Alternatives considered: reading `donkey.last_call` misses the call, because +ADK runs the model in a task of its own and the record stays there. Matching +the exception's status code to the last refusal could pair a refusal with the +wrong call. diff --git a/docs/verified-apis.md b/docs/verified-apis.md index 8942c20b..46f966ef 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -699,7 +699,7 @@ regardless of which issue/PR produced the offline pass. | Framework | Symbol / kwarg | Status | Verified value | Date | Source | |---|---|---|---|---|---| | LangGraph | `langchain_openai.ChatOpenAI(model, base_url, api_key, default_headers, http_async_client, max_retries=0, use_responses_api=True)` | UNVERIFIED (signature confirmed; pending maintainer `--live` + sign-off) | Construction and `isinstance` against the recorded path confirmed offline with `langgraph==1.2.12`, `langchain-openai==1.6.6`, `langchain-core==1.6.5`. The deep adapter (#198) retains `use_responses_api=True` for the live-verified `/responses` data plane (§2); overridable via `chat_model(..., use_responses_api=False)`. Tested with `openai==2.54.0`; no live round-trip. | 2026-09-27 | #34; `python/scripts/verify_frameworks.py --only langgraph` (also `--emit-verified`) | -| Google ADK | `google.adk.models.lite_llm.LiteLlm(model="openai/…", api_base, api_key, extra_headers, max_retries=0, client)` | UNVERIFIED (signature confirmed; pending maintainer `--live` + sign-off) | Construction and `isinstance` against the recorded path confirmed offline with `google-adk==2.10.0`, `litellm==1.102.1`. The pre-built OpenAI `client` sends through the SDK's shared client (#740), so every call carries the run's correlation id and `donkey.last_call` is `OBSERVED` after a call; this row does not verify request/header forwarding live. A refusal surfaces as a LiteLLM error with the openai `APIStatusError` in its `__cause__` chain; the SDK does not convert it to a typed error. Tested with `openai==2.54.0`; no live round-trip. `max_retries=0` goes to LiteLLM, which sets it on `client` per call (default 2); one send per budget 429 confirmed by request count against a loopback proxy with `litellm==1.103.2` (#734, `tests/unit/test_framework_retries.py`). | 2026-09-27 | #34; `python/scripts/verify_frameworks.py --only adk` (also `--emit-verified`) | +| Google ADK | `google.adk.models.lite_llm.LiteLlm(model="openai/…", api_base, api_key, extra_headers, max_retries=0, client, llm_client)` | UNVERIFIED (signature confirmed; pending maintainer `--live` + sign-off) | Construction and `isinstance` against the recorded path confirmed offline with `google-adk==2.10.0`, `litellm==1.102.1`. The pre-built OpenAI `client` sends through the SDK's shared client (#740), so every call carries the run's correlation id and `donkey.last_call` is `OBSERVED` after a call; this row does not verify request/header forwarding live. A refusal surfaces as a LiteLLM error (`litellm.exceptions.APIError` for a PII 403, `InternalServerError` for a transport-raised `ModelSubstituted` or `GatewayUnavailable`) with no gateway headers and no `__cause__`; confirmed offline with `google-adk==2.11.0`, `litellm==1.104.2`, `openai==2.54.0` (#969). `LiteLlm.llm_client` (a pydantic field typed `LiteLLMClient`, present from `google-adk==2.4.0`) is awaited as `llm_client.acompletion(...)` for every model request, streamed or not, so `model()` passes a `LiteLLMClient` subclass that tags LiteLLM's error with the refusal the transport recorded for that call, and the typed-refusal bridge raises it typed (`tests/conformance/test_typed_refusal_bridge.py`). Tested with `openai==2.54.0`; no live round-trip. `max_retries=0` goes to LiteLLM, which sets it on `client` per call (default 2); one send per budget 429 confirmed by request count against a loopback proxy with `litellm==1.103.2` (#734, `tests/unit/test_framework_retries.py`). | 2026-10-10 | #34; `python/scripts/verify_frameworks.py --only adk` (also `--emit-verified`) | | Google ADK (native Gemini) | `google.adk.models.Gemini(model, base_url, client_kwargs={api_key, http_options={base_url, api_version="", headers, timeout, httpx_async_client}})` | VERIFIED (LIVE) | Round-trip through a `Format=Gemini` proxy with `google-adk==2.10.0`, `google-genai==2.25.0`: `generate_content_async` → 200, SSE streaming, and a tool-calling agent run. `client_kwargs` **replaces** ADK's default `http_options` wholesale, so the adapter passes the full set; an injected `httpx_async_client` disables genai's aiohttp path, so buffered and streamed calls both go through the shared `DonkeyAsyncClient`. `api_key` is required by genai and sent as `x-goog-api-key` (gateway-ignored placeholder). `timeout` is milliseconds (`None` would disable it). genai raises its own `google.genai.errors.APIError` whose `.response` is the httpx response, so `classify(exc.response)` works. `Gemini.client_kwargs` first appears in google-adk **2.4.0** (absent in 2.0.0 and 2.3.0, checked 2026-10-01); ADK's pydantic config ignores unknown fields, so on older versions the governed client is dropped silently. The `adk` extra floors at `google-adk>=2.4`, `gemini()` refuses a `Gemini` lacking either field with `_verify.blocked(...)`, and the round-trip test runs in CI at 2.4.0 and the newest release (#735). ADK caches one genai `Client` per event loop, and the shared `DonkeyAsyncClient` keeps one connection pool per loop, so one Donkey serves repeated `asyncio.run(...)` calls (#807). | 2026-10-01 | #691, #735; `python/scripts/verify_frameworks.py --only adk.gemini --live` against `ddk-gemini-inbound` | | MS Agent Framework | `agent_framework.openai.OpenAIChatClient(model, base_url, api_key, default_headers)` — the **Responses API** client (`POST /responses`), built by `chat_client(..., api="responses")`; `agent_framework.openai.OpenAIChatCompletionClient(…)` with the same kwargs — the **Chat Completions** client (`POST /chat/completions`), built by `chat_client()` by default (#1043) | UNVERIFIED (signature confirmed; pending maintainer `--live` + sign-off) | Construction and `isinstance` against both recorded paths confirmed offline with `agent-framework==1.19.0` / `agent-framework-openai==1.14.4`. The constructor kwarg is `model=` — `model_id` is **not** accepted. `base_url`/`api_key`/`default_headers`/`async_client` are accepted by both, so `connection_kwargs()` serves either. Both chain the openai error as `__cause__` of `ChatClientException` (`OpenAIContentFilterException` for a content-filter 400), so `policy_middleware()` covers both. The default is `/chat/completions` (#1043), the only route every upstream serves (§2 per-upstream route matrix, #894); `/responses` is the opt-in for OpenAI-routed proxies. The switch traces back to an Azure OpenAI route that answered `/responses` with **404** `Resource not found` and `/chat/completions` with 200 in one live probe of `ddk-azure-openai-model-routing` (2026-09-30, #826); re-captured 2026-10-07 as the §2 fixture `src/donkey_kit/simulator/_fixtures/anypoint/azure_openai_routing/` (#896, moved in #895); the `/chat/completions` route itself is recorded in §2 (Chat Completions row). Also confirmed offline at the `agent-framework==1.13.0` extras floor, where the call reaches the proxy (`test_framework_redirects.py`). 1.12.0 constructs but sends elsewhere (§8.3, #743). No live round-trip of either client. (Reclassified from a bare `VERIFIED` per #681 — same offline-only evidence class as its peer rows; §0.3 reserves `VERIFIED` for a real-sandbox round-trip.) | 2026-10-01 | #520, #826 (`verify_frameworks.py --only agent_framework`) | | MS Agent Framework (middleware) | `agent_framework.chat_middleware` decorator; `ChatContext.stream` / `.result`; `ResponseStream.with_pull_context_manager(cm_factory)`; `agent_framework.exceptions.ChatClientException` chaining the openai error as `__cause__` | UNVERIFIED (signature confirmed; pending maintainer `--live` + sign-off) | Confirmed offline with `agent-framework==1.19.0`: `Agent(middleware=[...])` accepts a `@chat_middleware`-marked function (an undecorated `(context, next)` function whose first parameter is not annotated `ChatContext` raises `MiddlewareException: Cannot determine middleware type`). `OpenAIChatClient._handle_request_error` re-raises every openai error as `ChatClientException` from the original, so `policy_middleware()` runs the call inside the typed-refusal bridge (`core/refusals.py`, #724), whose chain walk reaches the `openai.APIStatusError` and classifies its response in core; the adapter's `refusal_translator` sees through `agent_framework.exceptions.AgentFrameworkException` (the base of `ChatClientException`, confirmed offline with `agent-framework-core==1.20.0`), so an unreachable gateway surfaces as `GatewayUnavailable`. A streamed refusal surfaces on the caller's pull, after the middleware has returned; `with_pull_context_manager` wraps each pull, so the same conversion applies. Pinned by `test_agent_framework_policy_middleware.py` on a real `Agent` (one send, typed `PIIDetected` with the run's correlation id, streaming and non-streaming). No live round-trip. | 2026-10-01 | #739 | diff --git a/python/README.md b/python/README.md index 013a3325..ec3cd89d 100644 --- a/python/README.md +++ b/python/README.md @@ -149,4 +149,4 @@ legitimately cannot satisfy a scenario, the reason is asserted in code | CrewAI | gateway identity observed | For the same reason, no response reaches the SDK's `_on_response` hook. When every resolved adapter is non-observing, `donkey.last_call` reports `UNAVAILABLE` and names them in `surface`. | | CrewAI | JWT refreshed per send | CrewAI's native OpenAI provider owns the transport and builds its own clients, so the rotating JWT the SDK adds per send never reaches its requests. `donkey.crewai.llm()` and `connection_kwargs()` raise `ConfigError` in `jwt` mode; use client-id auth with CrewAI. ADK's `model()` and `gemini()`, LlamaIndex and Microsoft Agent Framework send through the SDK's client and carry the rotating JWT on async calls ([`jwt` mode](https://docs.donkey-kit.dev/reference/configuration#jwt--model-wallet-auth-mode)). | | CrewAI | budget refusal not retried | CrewAI wraps every LLM call in its own rate-limit retry (3 attempts) and treats any `429` as a rate limit, so a `TokenBudgetExceeded` or `RequestRateLimitExceeded` refusal is sent 3 times. CrewAI has no setting to turn it off; the OpenAI client underneath has `max_retries=0`. Every other adapter sends a budget refusal once (#734). | -| ADK `model()`, CrewAI | typed refusal bridged | Each raises its own error for a refusal, for a different reason, so `donkey.run()` and `typed_refusals()` cannot tell a gateway refusal from any other failure there and pass those errors through (#724). For ADK's `model()`, LiteLLM sends through the SDK's transport (#946) but re-raises a refusal as its own exception around a response it rebuilt, which carries no sign the SDK sent it. For CrewAI, the native OpenAI provider owns the transport, so its errors come from a client the SDK never saw. ADK's `gemini()` is handed the SDK's httpx client and is bridged (conformance-tested in #955). | +| CrewAI | typed refusal bridged | CrewAI's native OpenAI provider owns the transport, so its errors come from a client the SDK never saw, and `donkey.run()` and `typed_refusals()` cannot tell a gateway refusal from any other failure there and pass those errors through (#724). ADK's `gemini()` and `model()` send through the SDK's transport and are bridged (conformance-tested in #955 and #969). LiteLLM, under `model()`, re-raises a refusal as its own exception, and the adapter's `llm_client` marks that exception with the refusal the SDK's transport received. | diff --git a/python/pyproject.toml b/python/pyproject.toml index c08b0cfd..130c4cb5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -254,9 +254,11 @@ ignore_missing_imports = true # fail the base typecheck; an inline ignore would go stale whenever it is present. # The CrewAI adapter's interceptor subclasses crewai's BaseInterceptor, likewise, # and the LangGraph adapter's last_call handler langchain_core's BaseCallbackHandler. -# The Strands adapter's model subclasses the OpenAIModel class it is handed (#951). +# The Strands adapter's model subclasses the OpenAIModel class it is handed (#951), +# and the ADK adapter's llm_client the LiteLLMClient class it is handed (#969). module = [ "donkey_kit.core.transport.httpx2", + "donkey_kit.integrations.adk", "donkey_kit.integrations.crewai", "donkey_kit.integrations.langgraph", "donkey_kit.integrations.strands", diff --git a/python/src/donkey_kit/core/refusals.py b/python/src/donkey_kit/core/refusals.py index 2bfb168d..a929dd9e 100644 --- a/python/src/donkey_kit/core/refusals.py +++ b/python/src/donkey_kit/core/refusals.py @@ -33,6 +33,15 @@ rebuilds the group with the same shape and each translatable leaf replaced by its typed error, so ``except* PIIDetected`` works; a group with no translatable leaf propagates as it is. +5. A link tagged by a :class:`RefusalCapture` stands for the typed error the + transport recorded for that call (#969). Some frameworks re-raise a refusal + around a response they rebuilt, so neither the chain nor the response leads + back to the governed one (LiteLLM, under ``donkey.adk.model()``). An adapter + opens :func:`capture_refusals` around one framework call; the transport + records each final outcome of a send in it (:func:`record_outcome`), and the + adapter tags the error the call raised with the last one before re-raising + it unchanged. Outside the bridge, the framework's error is what the caller + sees, as before. :class:`TypedRefusals` applies :func:`translate` on exit, as a sync or async context manager or a decorator. ``donkey.run()`` and ``@donkey.governed`` apply @@ -43,17 +52,27 @@ from __future__ import annotations import builtins +import contextlib import functools import inspect import logging import sys -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable, Iterator +from contextvars import ContextVar from types import TracebackType from typing import Any, ParamSpec, TypeVar, cast from .errors import DonkeyError, ResponseLike, classify -__all__ = ["Translator", "TypedRefusals", "translate"] +__all__ = [ + "RefusalCapture", + "Translator", + "TypedRefusals", + "capture_refusals", + "capturing", + "record_outcome", + "translate", +] _log = logging.getLogger(__name__) @@ -122,6 +141,9 @@ def _translate_one(exc: BaseException, hooks: tuple[Translator, ...]) -> DonkeyE seen.add(id(link)) if isinstance(link, DonkeyError): return link + tagged = getattr(link, _TAG, None) + if isinstance(tagged, DonkeyError): + return tagged for hook in hooks: try: typed = hook(link) @@ -233,6 +255,81 @@ def _carries_http(exc: BaseException) -> bool: return _attr(exc, "request") is not None or _attr(exc, "response") is not None +# The attribute :meth:`RefusalCapture.tag` sets on a framework error. An +# attribute rather than a weak map: the typed error later holds the framework +# error on ``framework_error``, and a weak map's value must not refer to its key. +_TAG = "__donkey_refusal__" + +_capture: ContextVar[RefusalCapture | None] = ContextVar("donkey_refusal_capture", default=None) + + +class RefusalCapture: + """The typed outcome of the governed sends made during one framework call + (#969), opened by :func:`capture_refusals`. + + ``error`` is the typed error of the last final response or failed send the + transport recorded, or ``None`` when the last one succeeded (or none was + sent). A send that started in the call and settles after it closed is not + recorded. + """ + + __slots__ = ("_open", "error") + + def __init__(self) -> None: + self.error: DonkeyError | None = None + self._open = True + + def close(self) -> None: + """Stop recording: the framework call is over.""" + self._open = False + + def record(self, error: DonkeyError | None) -> None: + """Keep ``error`` as the call's outcome, unless the capture is closed.""" + if self._open: + self.error = error + + def tag(self, exc: BaseException) -> None: + """Mark ``exc``, the error the framework call raised, as standing for the + recorded typed error, so :func:`translate` returns it. Does nothing when + no error was recorded, and never raises.""" + if self.error is None or exc is self.error: + return + try: + setattr(exc, _TAG, self.error) + except (AttributeError, TypeError): # an exception type without a __dict__ + _log.debug("cannot tag %r with its refusal", type(exc), exc_info=True) + + +@contextlib.contextmanager +def capture_refusals() -> Iterator[RefusalCapture]: + """Record the typed outcome of every governed send in the block (#969). + + For an adapter whose framework drops the governed response from the error + it raises: wrap one framework call, then :meth:`RefusalCapture.tag` the + error it raised. Captures nest; the innermost one records.""" + capture = RefusalCapture() + token = _capture.set(capture) + try: + yield capture + finally: + capture.close() + _capture.reset(token) + + +def capturing() -> bool: + """Whether a :func:`capture_refusals` block is open in this context.""" + return _capture.get() is not None + + +def record_outcome(error: DonkeyError | None) -> None: + """Record a governed send's final outcome in the open capture: its typed + error, or ``None`` for a success. Called by the transport; a no-op outside + :func:`capture_refusals`.""" + capture = _capture.get() + if capture is not None: + capture.record(error) + + class TypedRefusals: """Re-raise a refusal from the block as its typed :class:`DonkeyError`. diff --git a/python/src/donkey_kit/core/transport/pipeline.py b/python/src/donkey_kit/core/transport/pipeline.py index d28303b6..43308689 100644 --- a/python/src/donkey_kit/core/transport/pipeline.py +++ b/python/src/donkey_kit/core/transport/pipeline.py @@ -5,7 +5,9 @@ :class:`~donkey_kit.core.transport.DonkeyClient`. It holds the config, budget and header names, stamps the per-send headers, runs the sans-IO retry decision (:mod:`.policy`) with its log record, types a failed send, and settles the -final response: refusal, span attributes and substitution. The clients add +final response: refusal, span attributes and substitution. Inside a +:func:`~donkey_kit.core.refusals.capture_refusals` block it also records each +send's typed outcome there (#969). The clients add only what differs between them, which is how they wait and how they close. Before #728 each client carried its own copy of all of this, and the two had drifted. @@ -22,7 +24,15 @@ from ..budget import Budget from ..config import DonkeyConfig -from ..errors import ConfigError, GatewayUnavailable, ModelSubstituted, PolicyViolation +from ..errors import ( + ConfigError, + DonkeyError, + GatewayUnavailable, + ModelSubstituted, + PolicyViolation, + classify, +) +from ..refusals import capturing, record_outcome from ..telemetry import GenAiSpan from .failures import _gateway_unavailable, _lifecycle_error from .headers import ( @@ -146,12 +156,14 @@ def _decide( def _unreachable(self, request: httpx.Request, exc: httpx.TransportError) -> GatewayUnavailable: """A transport-level failure (DNS, refused, TLS, timeout) as the typed :class:`GatewayUnavailable`, carrying the ids already on the request (#379).""" - return _gateway_unavailable( + typed = _gateway_unavailable( request, exc, correlation_header=self._correlation_header, call_id_header=self._call_id_header, ) + record_outcome(typed) + return typed def _send_error( self, request: httpx.Request, exc: Exception, *, client_closed: bool @@ -164,13 +176,16 @@ def _send_error( if isinstance(exc, httpx.TransportError): return self._unreachable(request, exc) if isinstance(exc, RuntimeError): - return _lifecycle_error( + typed = _lifecycle_error( request, exc, client_closed=client_closed, correlation_header=self._correlation_header, call_id_header=self._call_id_header, ) + if typed is not None: + record_outcome(typed) + return typed return None def _settle( @@ -195,4 +210,22 @@ def _settle( correlation_header=self._correlation_header, cost_tags=effective_cost_tags(self._cfg), ) - return violation, _substitution_error(self._cfg, request, response) + substitution = _substitution_error(self._cfg, request, response) + if capturing(): + _record_settled(response, substitution) + return violation, substitution + + +def _record_settled(response: httpx.Response, substitution: ModelSubstituted | None) -> None: + """Record a final response's typed outcome in the open refusal capture + (#969): the substitution to raise, else the classified error of a non-2xx + response, else ``None``, so a success clears an earlier failure in the same + framework call. Never raises.""" + outcome: DonkeyError | None = substitution + try: + if outcome is None and response.status_code >= 400: + outcome = classify(response) + except Exception: # noqa: BLE001 — classification must never break the request + _log.debug("classifying the final response for the refusal capture failed", exc_info=True) + return + record_outcome(outcome) diff --git a/python/src/donkey_kit/integrations/adk.py b/python/src/donkey_kit/integrations/adk.py index f71e697e..0acb6c8b 100644 --- a/python/src/donkey_kit/integrations/adk.py +++ b/python/src/donkey_kit/integrations/adk.py @@ -13,7 +13,11 @@ carries the run's correlation id and populates ``donkey.last_call`` (#740). ADK requires ``litellm>=1.84`` (floor, not ceiling). LiteLLM sets the client's ``max_retries`` on every call (default 2), so the kwarg goes to - LiteLLM itself (#734). + LiteLLM itself (#734). LiteLLM re-raises a gateway refusal around a response + it rebuilt, so ``model()`` also passes ADK's ``llm_client`` hook: a + ``LiteLLMClient`` subclass that tags the error with the typed refusal the + transport recorded for that call, which the typed-refusal bridge then raises + (#969). A ``LiteLlm`` built from ``connection_kwargs()`` does not get it. * ``gemini()`` — ADK's native ``google.adk.models.Gemini`` for a ``Format=Gemini`` proxy (#691). The native route is ``POST /models/:generateContent`` (#540); the model travels in @@ -29,17 +33,19 @@ from __future__ import annotations +import functools from types import MappingProxyType from typing import TYPE_CHECKING, Any from ..core import _verify from ..core.masking import masked +from ..core.refusals import capture_refusals from . import AdapterCapabilities from ._base import Adapter, default_adapter if TYPE_CHECKING: from google.adk.models import Gemini - from google.adk.models.lite_llm import LiteLlm + from google.adk.models.lite_llm import LiteLlm, LiteLLMClient __all__ = ["ADKAdapter", "gemini", "model"] @@ -64,16 +70,16 @@ class ADKAdapter(Adapter): """ # Both send through the shared client and observe donkey.last_call (#691, - # #946). model() has no typed refusals: LiteLLM re-raises a refusal around a - # response it rebuilt, which the bridge leaves alone (#724). Each factory has - # its own capabilities (#741, #726). + # #946). model()'s typed refusals come from its llm_client, which tags + # LiteLLM's error with the refusal the transport recorded for the call + # (#969). Each factory has its own capabilities (#741, #726). factories = MappingProxyType( { "model": AdapterCapabilities( transport="shared", sync=False, streaming=True, - typed_refusals=False, + typed_refusals=True, observes_last_call=True, ), "gemini": AdapterCapabilities( @@ -138,11 +144,17 @@ def gemini_connection_kwargs(self, *, base_url: str | None = None) -> dict[str, def model(self, model: str, **kw: Any) -> LiteLlm: """Return ADK's ``LiteLlm`` at the proxy. An ``api_base``/``base_url`` - override must pass the https check.""" + override must pass the https check. + + Its ``llm_client`` is a ``LiteLLMClient`` subclass that lets a gateway + refusal reach ``donkey.run()`` as its typed class (#969, see the module + docstring). An ``llm_client`` you pass replaces it, and the refusal + then stays LiteLLM's error.""" self._allow_endpoints(kw, "api_base", "base_url") with self._native_import(): - from google.adk.models.lite_llm import ( - LiteLlm, # VERIFY name/path: docs/verified-apis.md §8 + from google.adk.models.lite_llm import ( # VERIFY name/path: docs/verified-apis.md §8 + LiteLlm, + LiteLLMClient, ) conn = self.connection_kwargs() @@ -150,6 +162,7 @@ def model(self, model: str, **kw: Any) -> LiteLlm: if override is not None and "client" in conn: # LiteLLM sends to the client's own base URL, so rebuild it there. conn["client"] = self._proxy_openai_client(str(override)) + conn["llm_client"] = _governed_llm_client_class(LiteLLMClient)() # LiteLLM's OpenAI-compatible route needs the ``openai/`` prefix. return LiteLlm(model=f"openai/{model}", **{**conn, **kw}) @@ -179,6 +192,35 @@ def gemini(self, model: str, *, base_url: str | None = None, **kw: Any) -> Gemin return Gemini(model=model, **{**conn, **kw}) +@functools.cache +def _governed_llm_client_class(base: Any) -> type[LiteLLMClient]: + """The subclass of ADK's ``LiteLLMClient`` (``base``) that + :meth:`ADKAdapter.model` passes as ``llm_client``, defined on first use so + ``google.adk`` is imported lazily (§1.1). + + ``LiteLlm`` awaits ``llm_client.acompletion`` for every model request, + streamed or not (docs/verified-apis.md §8). LiteLLM raises a gateway refusal + as its own error around a response it rebuilt (no gateway headers, no + cause), so the override opens a refusal capture around the call and tags + the error it raised with the typed error the transport recorded. The error + itself is re-raised unchanged; ``donkey.run()`` and ``typed_refusals()`` + raise the tagged refusal in its place.""" + + class GovernedLiteLLMClient(base): + """ADK's ``LiteLLMClient``, with LiteLLM's errors tagged with the typed + refusal behind them (#969).""" + + async def acompletion(self, *args: Any, **kwargs: Any) -> Any: + with capture_refusals() as capture: + try: + return await super().acompletion(*args, **kwargs) + except Exception as exc: + capture.tag(exc) + raise + + return GovernedLiteLLMClient + + def model(model: str, **kw: Any) -> LiteLlm: """Module-level convenience: a native ``LiteLlm`` at the proxy using a cached default env-configured Donkey. Equivalent to diff --git a/python/tests/conformance/suite.py b/python/tests/conformance/suite.py index 50351d5a..4c3c4a2b 100644 --- a/python/tests/conformance/suite.py +++ b/python/tests/conformance/suite.py @@ -98,8 +98,8 @@ # Documented, ASSERTED exemptions — published in the README (the conformance kit). A framework # that cannot satisfy a scenario records WHY here rather than skipping silently. # Every adapter but CrewAI sends through our shared httpx client (#740), so -# CrewAI is the only one listed for the transport scenarios; ADK's model() is -# listed for typed_refusal_bridged alone (#724). CrewAI's structural reason: the +# CrewAI is the only one listed, ADK's model() included since its refusals are +# bridged too (#969). CrewAI's structural reason: the # native OpenAI provider builds both its sync OpenAI and its AsyncOpenAI from # ONE client_params dict, and the openai SDK type-checks http_client per client, # so no single value can carry our clients to both. With an interceptor set it @@ -142,17 +142,12 @@ # typed_refusal_bridged: the bridge types an error only when the SDK's transport # raised it or sent the request behind it (#724, ADR 0002). A framework that owns -# the transport raises its own errors for a call the SDK never saw, and one that -# re-wraps a refusal around a response it rebuilt hides the one the SDK sent, so -# the bridge leaves both as they are rather than guess at their shape. Mirrors -# typed_refusals=False in the factory's AdapterCapabilities (#726). -_LITELLM_REFUSAL_EXEMPTION = ( - "adk.model() only: LiteLLM sends through our transport (#946) but re-raises a " - "refusal as its own exception around a response it rebuilt, which carries no " - "sign that our transport sent it; the typed-refusal bridge cannot tell a gateway " - "refusal from any other failure there, so it passes them through (#724). " - "adk.gemini() is handed our httpx client and is bridged (conformance-tested, #955)." -) +# the transport raises its own errors for a call the SDK never saw, so the bridge +# leaves them as they are rather than guess at their shape. Mirrors +# typed_refusals=False in the factory's AdapterCapabilities (#726). ADK's model() +# is not listed: LiteLLM re-raises a refusal around a response it rebuilt, and +# the adapter's llm_client tags that error with the refusal our transport +# recorded for the call (#969). _CREWAI_REFUSAL_EXEMPTION = ( "CrewAI's native OpenAI provider owns the transport, so its errors come from a " "client the SDK never saw; the typed-refusal bridge passes them through rather " @@ -160,10 +155,9 @@ ) KNOWN_LIMITATIONS: dict[str, dict[str, str]] = { - # ADK's model() and CrewAI each raise a framework error the bridge cannot type; - # adk.gemini() is handed our httpx client, records no exemption (#691) and is - # bridge-tested in test_typed_refusal_bridge.py (#955). - "adk": {"typed_refusal_bridged": _LITELLM_REFUSAL_EXEMPTION}, + # CrewAI raises a framework error the bridge cannot type. ADK records no + # exemption: adk.gemini() (#955) and adk.model() (#969) are both bridge-tested + # in test_typed_refusal_bridge.py. "crewai": { "correlation_id_propagated": _CREWAI_TRANSPORT_EXEMPTION, "gateway_identity_observed": _CREWAI_LAST_CALL_EXEMPTION, diff --git a/python/tests/conformance/test_readme_exemptions_table.py b/python/tests/conformance/test_readme_exemptions_table.py index 207d1616..17d7a271 100644 --- a/python/tests/conformance/test_readme_exemptions_table.py +++ b/python/tests/conformance/test_readme_exemptions_table.py @@ -38,7 +38,6 @@ # Table label -> the ADAPTERS keys the row names. _FRAMEWORKS = { "CrewAI": ("crewai",), - "ADK `model()`, CrewAI": ("adk", "crewai"), } diff --git a/python/tests/conformance/test_typed_refusal_bridge.py b/python/tests/conformance/test_typed_refusal_bridge.py index a5efb7de..7bbca920 100644 --- a/python/tests/conformance/test_typed_refusal_bridge.py +++ b/python/tests/conformance/test_typed_refusal_bridge.py @@ -10,22 +10,23 @@ ``OpenAIPermissionDeniedError`` subclasses, and google-genai (under ADK's ``Gemini``) a ``ClientError``. The OpenAI Agents SDK, LlamaIndex and Strands sit on the ``openai`` client and raise its errors; Agent Framework wraps them in -a ``ChatClientException``. +a ``ChatClientException``. LiteLLM (under ADK's ``LiteLlm``) raises its own +``APIError`` or ``InternalServerError`` around a response it rebuilt. -Eight surfaces (the raw OpenAI client, the Anthropic client, a LangGraph graph, +Ten surfaces (the raw OpenAI client, the Anthropic client, a LangGraph graph, an OpenAI Agents SDK ``Runner`` run, a LlamaIndex ``FunctionAgent`` run, an ADK -``LlmAgent`` on ``adk.gemini()`` (#955), a Strands ``Agent`` and an Agent -Framework ``Agent`` (#983)) times three errors, each raised by the real -transport: +``LlmAgent`` on ``adk.gemini()`` (#955) and on ``adk.model()``, buffered and +streamed (#969), a Strands ``Agent`` and an Agent Framework ``Agent`` (#983)) +times three errors, each raised by the real transport: * ``PIIDetected`` — ``donkey.simulate(PIIDetected)`` serves the captured 403. * ``ModelSubstituted`` — with ``on_model_substitution="raise"``, a 200 whose served-model header names another model. * ``GatewayUnavailable`` — the transport cannot connect. -Each runs in both scopes. ``importorskip``-guarded per framework. ``adk.model()`` -and CrewAI are exempt (``KNOWN_LIMITATIONS``), as their -``capabilities().typed_refusals`` says (#726). +Each runs in both scopes. ``importorskip``-guarded per framework. CrewAI is +exempt (``KNOWN_LIMITATIONS``), as its ``capabilities().typed_refusals`` says +(#726). """ from __future__ import annotations @@ -66,7 +67,7 @@ def _cfg() -> DonkeyConfig: ) -# --- the six surfaces ------------------------------------------------------- +# --- the surfaces ------------------------------------------------------------ async def _openai(donkey: Donkey) -> None: @@ -115,18 +116,40 @@ async def _llamaindex(donkey: Donkey) -> None: await agent.run(user_msg="hi") -async def _adk_gemini(donkey: Donkey) -> None: +async def _adk_run(model: Any, *, streaming: bool = False) -> None: + """One ``LlmAgent`` turn on ``model`` through ADK's ``InMemoryRunner``.""" adk_agents = pytest.importorskip("google.adk.agents") + run_config = pytest.importorskip("google.adk.agents.run_config") runners = pytest.importorskip("google.adk.runners") types = pytest.importorskip("google.genai.types") - agent = adk_agents.LlmAgent(name="bridge", model=donkey.adk.gemini("gemini-2.5-flash")) + agent = adk_agents.LlmAgent(name="bridge", model=model) runner = runners.InMemoryRunner(agent=agent, app_name="bridge") session = await runner.session_service.create_session(app_name="bridge", user_id="u") message = types.Content(role="user", parts=[types.Part(text="hi")]) - async for _ in runner.run_async(user_id="u", session_id=session.id, new_message=message): + mode = run_config.StreamingMode.SSE if streaming else run_config.StreamingMode.NONE + config = run_config.RunConfig(streaming_mode=mode) + async for _ in runner.run_async( + user_id="u", session_id=session.id, new_message=message, run_config=config + ): pass +async def _adk_gemini(donkey: Donkey) -> None: + pytest.importorskip("google.adk.models") + await _adk_run(donkey.adk.gemini("gemini-2.5-flash")) + + +async def _adk_model(donkey: Donkey) -> None: + pytest.importorskip("google.adk.models.lite_llm") + await _adk_run(donkey.adk.model("gpt-4o")) + + +async def _adk_model_streamed(donkey: Donkey) -> None: + # LiteLlm awaits the same llm_client.acompletion for a streamed turn (#969). + pytest.importorskip("google.adk.models.lite_llm") + await _adk_run(donkey.adk.model("gpt-4o"), streaming=True) + + async def _strands(donkey: Donkey) -> None: strands = pytest.importorskip("strands") pytest.importorskip("strands.models.openai") @@ -150,6 +173,8 @@ async def _agent_framework(donkey: Donkey) -> None: "openai_agents": _openai_agents, "llamaindex": _llamaindex, "adk.gemini": _adk_gemini, + "adk.model": _adk_model, + "adk.model.streamed": _adk_model_streamed, "strands": _strands, "agent_framework": _agent_framework, } @@ -200,10 +225,10 @@ async def handle() -> None: def test_scenario_is_registered() -> None: assert "typed_refusal_bridged" in CONFORMANCE_SCENARIOS - # Only the adapters whose framework owns the transport are exempt: no - # response or transport error of theirs ever passes through the SDK. + # Only the adapter whose framework owns the transport is exempt: no + # response or transport error of its ever passes through the SDK. exempt = {a for a, limits in KNOWN_LIMITATIONS.items() if "typed_refusal_bridged" in limits} - assert exempt == {"adk", "crewai"} + assert exempt == {"crewai"} #: The adapter factory behind each surface; the raw OpenAI client is no adapter. @@ -213,6 +238,8 @@ def test_scenario_is_registered() -> None: "openai_agents": ("openai_agents", "model"), "llamaindex": ("llamaindex", "llm"), "adk.gemini": ("adk", "gemini"), + "adk.model": ("adk", "model"), + "adk.model.streamed": ("adk", "model"), "strands": ("strands", "model"), "agent_framework": ("agent_framework", "chat_client"), } @@ -220,8 +247,8 @@ def test_scenario_is_registered() -> None: def test_bridged_surfaces_declare_typed_refusals() -> None: # The factories that declare typed_refusals=True are exactly the surfaces - # proven below, and only the exempt adk.model() and CrewAI declare False - # (#726, #983), so the claim and the test cannot drift. + # proven below, and only the exempt CrewAI declares False (#726, #983, + # #969), so the claim and the test cannot drift. assert set(_FACTORIES) == set(SURFACES) - {"openai"} declared: dict[tuple[str, str], bool] = {} for attr, spec in ADAPTERS.items(): @@ -229,10 +256,7 @@ def test_bridged_surfaces_declare_typed_refusals() -> None: for factory, caps in getattr(module, spec.cls).factories.items(): declared[(attr, factory)] = caps.typed_refusals assert {key for key, typed in declared.items() if typed} == set(_FACTORIES.values()) - assert {key for key, typed in declared.items() if not typed} == { - ("adk", "model"), - ("crewai", "llm"), - } + assert {key for key, typed in declared.items() if not typed} == {("crewai", "llm")} @pytest.mark.parametrize("scope", SCOPES) @@ -262,7 +286,8 @@ async def test_refusal_reaches_user_code_typed( # through as it is, so there is no wrapper to keep; under openai<3 the same # case arrives as an APIConnectionError and the bridge unwraps it. # google-genai (adk.gemini()) lets a transport-raised typed error through on - # every version. + # every version. LiteLLM (adk.model()) never does: every case arrives as its + # own error, kept on framework_error. assert type(excinfo.value) is error framework_error = excinfo.value.framework_error if error is PIIDetected: diff --git a/python/tests/unit/test_adapter_capabilities.py b/python/tests/unit/test_adapter_capabilities.py index c58f93fe..08ce6fca 100644 --- a/python/tests/unit/test_adapter_capabilities.py +++ b/python/tests/unit/test_adapter_capabilities.py @@ -221,11 +221,15 @@ def test_adk_model_and_gemini_report_separately(http: DonkeyAsyncClient) -> None adk = _adapter_class("adk")(_client_id_cfg(), http) model = adk.capabilities("model") gemini = adk.capabilities("gemini") - assert model != gemini + # Each factory reports its own entry, and model() is the default one. + assert (model, gemini) == (adk.factories["model"], adk.factories["gemini"]) + assert model is not gemini assert adk.capabilities() is model - # Both observe since #946; only model() lacks typed refusals (#724). + # Both observe since #946, and both bridge typed refusals since #969 (they + # differed on typed_refusals before it), so today the two entries agree. assert model.observes_last_call and gemini.observes_last_call - assert (model.typed_refusals, gemini.typed_refusals) == (False, True) + assert model.typed_refusals and gemini.typed_refusals + assert model == gemini def test_strands_reports_streaming_off() -> None: diff --git a/python/tests/unit/test_adapter_ergonomics.py b/python/tests/unit/test_adapter_ergonomics.py index 5a25870c..b7942283 100644 --- a/python/tests/unit/test_adapter_ergonomics.py +++ b/python/tests/unit/test_adapter_ergonomics.py @@ -420,6 +420,8 @@ class _F(NamedTuple): ("gpt-4o",), "api_base", "https://override", + # model() passes a LiteLLMClient subclass as llm_client (#969) + (("google.adk.models.lite_llm", "LiteLLMClient"),), ), _F( "strands", @@ -710,6 +712,7 @@ def test_adk_last_call_status_does_not_depend_on_factory_order( _install_gemini_stub(monkeypatch) _install_native_stub(monkeypatch, "google.adk.models.lite_llm", "LiteLlm") + _install_native_stub(monkeypatch, "google.adk.models.lite_llm", "LiteLLMClient") monkeypatch.setattr("donkey_kit.donkey._missing_module", lambda _probe: None) token = lastcall._last_call.set(None) try: diff --git a/python/tests/unit/test_refusal_capture.py b/python/tests/unit/test_refusal_capture.py new file mode 100644 index 00000000..ff9e093a --- /dev/null +++ b/python/tests/unit/test_refusal_capture.py @@ -0,0 +1,313 @@ +"""The per-call refusal capture behind ADK ``model()``'s typed refusals (#969). + +LiteLLM re-raises a gateway refusal around a response it rebuilt, so nothing on +its error leads back to the SDK's transport. ``core.refusals.capture_refusals`` +records the typed outcome of each governed send made during one framework call, +the adapter tags the framework's error with it, and ``translate()`` returns the +tag. Base-only: the transport cases use ``httpx.MockTransport``, and the ADK +client subclass is built over a stand-in base, so no framework extra is needed. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from donkey_kit.core import _wire +from donkey_kit.core.config import DonkeyConfig +from donkey_kit.core.errors import ( + ConfigError, + DonkeyError, + GatewayUnavailable, + ModelSubstituted, + PIIDetected, +) +from donkey_kit.core.refusals import ( + RefusalCapture, + TypedRefusals, + capture_refusals, + capturing, + record_outcome, + translate, +) +from donkey_kit.core.transport import DonkeyAsyncClient, DonkeyClient, pipeline +from donkey_kit.integrations.adk import _governed_llm_client_class + +_URL = "https://proxy.example/chat/completions" +_CFG = DonkeyConfig( + llm_proxy_url="https://proxy.example/", + llm_proxy_client_id="cid-capture", + llm_proxy_client_secret="csecret-capture", + max_retries=0, +) +_PII_BODY = {"error": {"type": "pii_detected", "message": 'blocked: [{"pii_type": "EMAIL"}]'}} +_Handler = Callable[[httpx.Request], httpx.Response] + +KINDS = ["async", "sync"] + + +def _pii(request: httpx.Request) -> httpx.Response: + return httpx.Response(403, json=_PII_BODY) + + +def _ok(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"model": "gpt-4o"}) + + +def _unreachable(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + +def _substituted(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={_wire.LLM_MODEL_HEADER: "other"}, json={"model": "x"}) + + +def _send_all(kind: str, handlers: list[_Handler], *, cfg: DonkeyConfig = _CFG) -> None: + """One send per handler, through one fresh client of ``kind``. A send that + raises is swallowed: the capture, not the raise, is under test.""" + replies = iter(handlers) + transport = httpx.MockTransport(lambda request: next(replies)(request)) + body = {"model": "gpt-4o"} + if kind == "sync": + with DonkeyClient(cfg, transport=transport) as client: + for _ in handlers: + try: + client.post(_URL, json=body) + except DonkeyError: + pass + return + + async def run() -> None: + async with DonkeyAsyncClient(cfg, None, transport=transport) as client: + for _ in handlers: + try: + await client.post(_URL, json=body) + except DonkeyError: + pass + + asyncio.run(run()) + + +# --- the capture itself --------------------------------------------------------- + + +def test_record_outcome_outside_a_capture_is_a_no_op() -> None: + assert not capturing() + record_outcome(PIIDetected("blocked")) # nothing to record into; must not raise + assert not capturing() + + +def test_a_capture_records_the_last_outcome_and_closes_on_exit() -> None: + first, second = PIIDetected("blocked"), GatewayUnavailable("down") + with capture_refusals() as capture: + assert capturing() + record_outcome(first) + record_outcome(second) + assert capture.error is second + record_outcome(None) # a success clears an earlier failure + assert capture.error is None + record_outcome(first) + assert not capturing() + record_outcome(second) # after the call: not recorded + assert capture.error is first + + +def test_captures_nest_and_the_innermost_records() -> None: + error = PIIDetected("blocked") + with capture_refusals() as outer: + with capture_refusals() as inner: + record_outcome(error) + assert capturing() + assert (outer.error, inner.error) == (None, error) + + +def test_a_capture_is_shared_with_tasks_started_inside_it() -> None: + error = PIIDetected("blocked") + + async def send() -> None: + record_outcome(error) + + async def call() -> None: + # The transport may send from a task of its own; the task's copied + # context still points at the same capture. + await asyncio.create_task(send()) + + with capture_refusals() as capture: + asyncio.run(call()) + assert capture.error is error + + +# --- tagging and the bridge ----------------------------------------------------- + + +class LiteLLMLikeError(Exception): + """Shaped like LiteLLM's re-raise: a status code, no response, no cause.""" + + status_code = 403 + + +def test_a_tagged_error_translates_to_its_recorded_refusal() -> None: + typed = PIIDetected("blocked") + error = LiteLLMLikeError("403 blocked") + with capture_refusals() as capture: + record_outcome(typed) + capture.tag(error) + assert translate(error) is typed + resolved = TypedRefusals().resolve(error) + assert resolved is typed + assert typed.framework_error is error + + +def test_an_untagged_error_still_passes_through() -> None: + assert translate(LiteLLMLikeError("403 blocked")) is None + assert TypedRefusals().resolve(LiteLLMLikeError("403 blocked")) is None + + +def test_tag_does_nothing_when_the_call_succeeded() -> None: + error = LiteLLMLikeError("a bug in a callback") + with capture_refusals() as capture: + record_outcome(PIIDetected("blocked")) + record_outcome(None) + capture.tag(error) + assert translate(error) is None + + +def test_a_tag_on_a_chained_link_is_found_by_the_walk() -> None: + typed = GatewayUnavailable("down") + inner = LiteLLMLikeError("500") + capture = RefusalCapture() + capture.record(typed) + capture.tag(inner) + + class Wrapper(Exception): + request = object() # an HTTP-shaped link, so the walk steps through it + + outer = Wrapper("wrapped") + outer.__cause__ = inner + assert translate(outer) is typed + + +def test_tag_never_raises_on_an_exception_that_refuses_attributes() -> None: + class Frozen(Exception): + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError(name) + + capture = RefusalCapture() + capture.record(PIIDetected("blocked")) + error = Frozen("x") + capture.tag(error) + assert translate(error) is None + + +# --- the transport records, on both clients ------------------------------------- + + +@pytest.mark.parametrize("kind", KINDS) +def test_both_clients_record_a_refusal_response(kind: str) -> None: + with capture_refusals() as capture: + _send_all(kind, [_pii]) + assert type(capture.error) is PIIDetected + + +@pytest.mark.parametrize("kind", KINDS) +def test_both_clients_record_a_success_after_a_refusal_as_none(kind: str) -> None: + with capture_refusals() as capture: + _send_all(kind, [_pii, _ok]) + assert capture.error is None + + +@pytest.mark.parametrize("kind", KINDS) +def test_both_clients_record_an_unreachable_gateway(kind: str) -> None: + with capture_refusals() as capture: + _send_all(kind, [_unreachable]) + assert type(capture.error) is GatewayUnavailable + + +@pytest.mark.parametrize("kind", KINDS) +def test_both_clients_record_a_raised_substitution(kind: str) -> None: + cfg = dataclasses.replace(_CFG, on_model_substitution="raise") + with capture_refusals() as capture: + _send_all(kind, [_substituted], cfg=cfg) + assert type(capture.error) is ModelSubstituted + + +@pytest.mark.parametrize("kind", KINDS) +def test_both_clients_record_a_send_on_a_closed_client(kind: str) -> None: + transport = httpx.MockTransport(_ok) + with capture_refusals() as capture: + if kind == "sync": + client = DonkeyClient(_CFG, transport=transport) + client.close() + with pytest.raises(ConfigError): + client.post(_URL, json={"model": "gpt-4o"}) + else: + + async def run() -> None: + client = DonkeyAsyncClient(_CFG, None, transport=transport) + await client.aclose() + with pytest.raises(ConfigError): + await client.post(_URL, json={"model": "gpt-4o"}) + + asyncio.run(run()) + assert type(capture.error) is ConfigError + + +@pytest.mark.parametrize("kind", KINDS) +def test_a_failing_classification_never_breaks_the_send( + kind: str, monkeypatch: pytest.MonkeyPatch +) -> None: + def broken(response: object) -> DonkeyError: + raise ValueError("broken") + + monkeypatch.setattr(pipeline, "classify", broken) + earlier = GatewayUnavailable("down") + with capture_refusals() as capture: + record_outcome(earlier) + _send_all(kind, [_pii]) # the 403 is still returned to the caller + assert capture.error is earlier + + +# --- the ADK llm_client subclass ------------------------------------------------ + + +class _Base: + """Stands in for ADK's ``LiteLLMClient``: one send through the transport's + recording, then LiteLLM's own error.""" + + def __init__(self, outcome: DonkeyError | None, error: Exception | None) -> None: + self.outcome, self.error = outcome, error + + async def acompletion(self, *args: Any, **kwargs: Any) -> str: + record_outcome(self.outcome) + if self.error is not None: + raise self.error + return "response" + + +def test_the_adk_client_tags_litellms_error_and_reraises_it_unchanged() -> None: + governed = _governed_llm_client_class(_Base) + typed, error = PIIDetected("blocked"), LiteLLMLikeError("403 blocked") + with pytest.raises(LiteLLMLikeError) as excinfo: + asyncio.run(governed(typed, error).acompletion(model="openai/gpt-4o", messages=[])) + assert excinfo.value is error + assert translate(error) is typed + assert not capturing() + + +def test_the_adk_client_leaves_a_success_and_an_unrelated_error_alone() -> None: + governed = _governed_llm_client_class(_Base) + assert asyncio.run(governed(None, None).acompletion()) == "response" + error = LiteLLMLikeError("not a refusal") + with pytest.raises(LiteLLMLikeError): + asyncio.run(governed(None, error).acompletion()) + assert translate(error) is None + + +def test_the_adk_client_class_is_built_once_per_base() -> None: + assert _governed_llm_client_class(_Base) is _governed_llm_client_class(_Base) + assert issubclass(_governed_llm_client_class(_Base), _Base) diff --git a/website/content/errors.mdx b/website/content/errors.mdx index 15e47194..00553c45 100644 --- a/website/content/errors.mdx +++ b/website/content/errors.mdx @@ -391,10 +391,9 @@ in a group before matching. On Python 3.10, which has no builtin that anyio raises. - The bridge only sees calls that went through the SDK's transport. ADK's - `model()` (LiteLLM) and CrewAI own their transport, so their errors pass - through untyped. These are the same - [conformance exemptions](/testing#exemptions) as their correlation ids. + The bridge only sees calls that went through the SDK's transport. CrewAI + owns its transport, so its errors pass through untyped. This is the same + [conformance exemption](/testing#exemptions) as its correlation id. ### Classifying a response yourself diff --git a/website/content/examples/adk.mdx b/website/content/examples/adk.mdx index 7792aa0c..e4855c00 100644 --- a/website/content/examples/adk.mdx +++ b/website/content/examples/adk.mdx @@ -1,5 +1,5 @@ --- -description: Two Google ADK scripts — a LiteLlm model from donkey.adk.model() in an InMemoryRunner, and why a PII refusal is not typed on the LiteLLM path. +description: Two Google ADK scripts — a LiteLlm model from donkey.adk.model() in an InMemoryRunner, and a PII refusal raised outside donkey.run(), where it stays LiteLLM's error. --- import { Callout } from 'nextra/components' @@ -8,9 +8,9 @@ import { Callout } from 'nextra/components' Google ADK with `donkey.adk.model("…")`, a `LiteLlm` model. LiteLLM calls the proxy's `/chat/completions` route. It sends through the SDK's shared HTTP client, so the credentials and the run id -go on the wire, but LiteLLM raises its own errors, so the SDK does not convert -a refusal to a **typed error** here; the `openai` error is in the `__cause__` -chain. Script 02 exists to show that gap. +go on the wire. LiteLLM raises its own errors, so a refusal becomes a **typed +error** only inside `donkey.run()` or `@donkey.governed`. Script 02 calls the +agent outside them, so it shows LiteLLM's own error. Both scripts need a live gateway; there is no offline ADK script. @@ -53,7 +53,7 @@ in an `after_model_callback` instead, as shown under [Native Gemini](/frameworks/adk#native-gemini). LiteLLM also logs a provider-list banner, which is harmless. -## 02 — A refusal that is not typed +## 02 — A refusal outside `donkey.run()` ```bash python "demos/human-made/adk/02 - refusal-live.py" @@ -61,7 +61,8 @@ python "demos/human-made/adk/02 - refusal-live.py" A PII prompt against a proxy with the PII policy applied. LiteLLM keeps the status code and the message but drops the response headers, so `classify()` -has nothing to read. The script therefore catches LiteLLM's own error: +has nothing to read. The script makes the call outside `donkey.run()`, so it +catches LiteLLM's own error: ```python try: @@ -76,13 +77,25 @@ except litellm.exceptions.APIError as err: should see:** `APIError 403` and the first line of the proxy's message — **not** `PIIDetected`. Without the policy it prints `NO REFUSAL`. +Wrap the same call in `donkey.run()` and the refusal reaches your code as +`PIIDetected`, with LiteLLM's `APIError` on `framework_error`: + +```python +async def main(): + async with donkey.run(): + await InMemoryRunner(agent=agent).run_debug(PII_PROMPT, quiet=True) + +try: + asyncio.run(main()) +except PIIDetected as err: + print(type(err).__name__, type(err.framework_error).__name__) +``` + - If you need typed refusals with ADK, use - `donkey.adk.gemini("gemini-2.5-flash")` on a `Format=Gemini` proxy and - `classify()` its error (see [Native Gemini](/frameworks/adk#native-gemini)). - Otherwise prefer a framework path where the SDK owns the transport, such as - [OpenAI](/examples/openai) or [LangGraph](/examples/langgraph). A `404` - here means the proxy's upstream has no `/chat/completions` route. + Typed refusals on `model()` need the `LiteLlm` that `donkey.adk.model()` + returns. One you build from `connection_kwargs()` raises LiteLLM's errors + even inside `donkey.run()` (see [Google ADK](/frameworks/adk#manual-equivalent)). + A `404` here means the proxy's upstream has no `/chat/completions` route. **Learn more:** [Google ADK](/frameworks/adk) diff --git a/website/content/examples/index.mdx b/website/content/examples/index.mdx index 14ea4900..7c213807 100644 --- a/website/content/examples/index.mdx +++ b/website/content/examples/index.mdx @@ -240,7 +240,7 @@ headers go on the wire. An `OpenAILike` for `complete()` and `chat()`; typed refusals. - A `LiteLlm` model in an `InMemoryRunner` — and why its refusals are not typed. + A `LiteLlm` model in an `InMemoryRunner`, and a refusal outside `donkey.run()`. The native client on `/v1/messages`, `last_call` and simulated refusals. diff --git a/website/content/frameworks/adk.mdx b/website/content/frameworks/adk.mdx index 2afbaed1..d0753c93 100644 --- a/website/content/frameworks/adk.mdx +++ b/website/content/frameworks/adk.mdx @@ -26,7 +26,8 @@ of two ways, depending on the proxy's ingress **Format**: automatically. Supported at `connection_kwargs()`. LiteLLM gets a pre-built OpenAI `client` that sends through the SDK's shared HTTP client, so per-run correlation, retries, spans, `donkey.last_call` and the `jwt`-mode JWT apply - (see [Notes](#notes)). + (see [Notes](#notes)). Inside `donkey.run()` or `@donkey.governed`, a + refusal reaches your code as its typed Donkey error, such as `PIIDetected`. - For `gemini()`: the shared client injected through `HttpOptions.httpx_async_client`, round-trip verified live against a `Format=Gemini` proxy. @@ -119,6 +120,11 @@ call, and its default is 2. An `api_base` / [`https://` rule](/reference/configuration#endpoints-must-use-https), and the adapter builds `client` on that URL. +`model()` also sets ADK's `llm_client` to a `LiteLLMClient` subclass, which is +what lets a refusal arrive typed (see [Notes](#notes)). `connection_kwargs()` +leaves it out, so a `LiteLlm` you build yourself raises LiteLLM's own errors +for a refusal. An `llm_client` you pass to `model()` replaces the SDK's. + ## Native Gemini A proxy provisioned with **Format = Gemini** exposes the native Gemini API at @@ -248,9 +254,15 @@ run in `asyncio.run(...)` can build the `Donkey` (or call the module-level `after_model_callback` (see [Native Gemini](#native-gemini)). A cold read on a `Donkey` that resolved only ADK reports `UNOBSERVED`, and `OBSERVED` after a call, for `model()` as well as `gemini()`. -- **Refusals on the `model()` path aren't converted to a typed error by the - SDK.** A refusal surfaces as a LiteLLM error whose `__cause__` chain - contains the `openai` `APIStatusError`; see the +- **Refusals on the `model()` path are typed inside `donkey.run()` and + `@donkey.governed`.** LiteLLM re-raises a gateway refusal as its own error, + such as `litellm.exceptions.APIError`, without the gateway's response + headers. The `llm_client` that `model()` sets marks that error with the + refusal the SDK's transport received for the same call, and the + [typed-refusal bridge](/errors#typed-refusals-at-the-framework-boundary) + raises `PIIDetected`, `ModelSubstituted` or `GatewayUnavailable` in its + place, with LiteLLM's error on `framework_error`. Outside those scopes, and + without `typed_refusals()`, you get LiteLLM's error unchanged; see the [ADK examples](/examples/adk). - **`gemini()` needs `google-adk>=2.4`**, the first release whose `Gemini` accepts `client_kwargs`; the `adk` extra declares that floor. On an older diff --git a/website/public/errors.md b/website/public/errors.md index de52e9eb..a6e02456 100644 --- a/website/public/errors.md +++ b/website/public/errors.md @@ -383,10 +383,9 @@ in a group before matching. On Python 3.10, which has no builtin `ExceptionGroup`, the bridge recognises the `exceptiongroup` backport's groups that anyio raises. - The bridge only sees calls that went through the SDK's transport. ADK's - `model()` (LiteLLM) and CrewAI own their transport, so their errors pass - through untyped. These are the same - [conformance exemptions](https://docs.donkey-kit.dev/testing.md#exemptions) as their correlation ids. + The bridge only sees calls that went through the SDK's transport. CrewAI + owns its transport, so its errors pass through untyped. This is the same + [conformance exemption](https://docs.donkey-kit.dev/testing.md#exemptions) as its correlation id. ### Classifying a response yourself diff --git a/website/public/examples.md b/website/public/examples.md index 9cb3f04a..3bce7336 100644 --- a/website/public/examples.md +++ b/website/public/examples.md @@ -218,7 +218,7 @@ headers go on the wire. An `OpenAILike` for `complete()` and `chat()`; typed refusals. - A `LiteLlm` model in an `InMemoryRunner` — and why its refusals are not typed. + A `LiteLlm` model in an `InMemoryRunner`, and a refusal outside `donkey.run()`. The native client on `/v1/messages`, `last_call` and simulated refusals. diff --git a/website/public/examples/adk.md b/website/public/examples/adk.md index 264a2d14..9c1da02a 100644 --- a/website/public/examples/adk.md +++ b/website/public/examples/adk.md @@ -2,9 +2,9 @@ Google ADK with `donkey.adk.model("…")`, a `LiteLlm` model. LiteLLM calls the proxy's `/chat/completions` route. It sends through the SDK's shared HTTP client, so the credentials and the run id -go on the wire, but LiteLLM raises its own errors, so the SDK does not convert -a refusal to a **typed error** here; the `openai` error is in the `__cause__` -chain. Script 02 exists to show that gap. +go on the wire. LiteLLM raises its own errors, so a refusal becomes a **typed +error** only inside `donkey.run()` or `@donkey.governed`. Script 02 calls the +agent outside them, so it shows LiteLLM's own error. Both scripts need a live gateway; there is no offline ADK script. @@ -47,7 +47,7 @@ in an `after_model_callback` instead, as shown under [Native Gemini](https://docs.donkey-kit.dev/frameworks/adk.md#native-gemini). LiteLLM also logs a provider-list banner, which is harmless. -## 02 — A refusal that is not typed +## 02 — A refusal outside `donkey.run()` ```bash python "demos/human-made/adk/02 - refusal-live.py" @@ -55,7 +55,8 @@ python "demos/human-made/adk/02 - refusal-live.py" A PII prompt against a proxy with the PII policy applied. LiteLLM keeps the status code and the message but drops the response headers, so `classify()` -has nothing to read. The script therefore catches LiteLLM's own error: +has nothing to read. The script makes the call outside `donkey.run()`, so it +catches LiteLLM's own error: ```python try: @@ -70,12 +71,24 @@ except litellm.exceptions.APIError as err: should see:** `APIError 403` and the first line of the proxy's message — **not** `PIIDetected`. Without the policy it prints `NO REFUSAL`. - If you need typed refusals with ADK, use - `donkey.adk.gemini("gemini-2.5-flash")` on a `Format=Gemini` proxy and - `classify()` its error (see [Native Gemini](https://docs.donkey-kit.dev/frameworks/adk.md#native-gemini)). - Otherwise prefer a framework path where the SDK owns the transport, such as - [OpenAI](https://docs.donkey-kit.dev/examples/openai.md) or [LangGraph](https://docs.donkey-kit.dev/examples/langgraph.md). A `404` - here means the proxy's upstream has no `/chat/completions` route. +Wrap the same call in `donkey.run()` and the refusal reaches your code as +`PIIDetected`, with LiteLLM's `APIError` on `framework_error`: + +```python +async def main(): + async with donkey.run(): + await InMemoryRunner(agent=agent).run_debug(PII_PROMPT, quiet=True) + +try: + asyncio.run(main()) +except PIIDetected as err: + print(type(err).__name__, type(err.framework_error).__name__) +``` + + Typed refusals on `model()` need the `LiteLlm` that `donkey.adk.model()` + returns. One you build from `connection_kwargs()` raises LiteLLM's errors + even inside `donkey.run()` (see [Google ADK](https://docs.donkey-kit.dev/frameworks/adk.md#manual-equivalent)). + A `404` here means the proxy's upstream has no `/chat/completions` route. **Learn more:** [Google ADK](https://docs.donkey-kit.dev/frameworks/adk.md) diff --git a/website/public/frameworks/adk.md b/website/public/frameworks/adk.md index 0a72bee5..7574c91b 100644 --- a/website/public/frameworks/adk.md +++ b/website/public/frameworks/adk.md @@ -19,7 +19,8 @@ of two ways, depending on the proxy's ingress **Format**: automatically. Supported at `connection_kwargs()`. LiteLLM gets a pre-built OpenAI `client` that sends through the SDK's shared HTTP client, so per-run correlation, retries, spans, `donkey.last_call` and the `jwt`-mode JWT apply - (see [Notes](#notes)). + (see [Notes](#notes)). Inside `donkey.run()` or `@donkey.governed`, a + refusal reaches your code as its typed Donkey error, such as `PIIDetected`. - For `gemini()`: the shared client injected through `HttpOptions.httpx_async_client`, round-trip verified live against a `Format=Gemini` proxy. @@ -120,6 +121,11 @@ call, and its default is 2. An `api_base` / [`https://` rule](https://docs.donkey-kit.dev/reference/configuration.md#endpoints-must-use-https), and the adapter builds `client` on that URL. +`model()` also sets ADK's `llm_client` to a `LiteLLMClient` subclass, which is +what lets a refusal arrive typed (see [Notes](#notes)). `connection_kwargs()` +leaves it out, so a `LiteLlm` you build yourself raises LiteLLM's own errors +for a refusal. An `llm_client` you pass to `model()` replaces the SDK's. + ## Native Gemini A proxy provisioned with **Format = Gemini** exposes the native Gemini API at @@ -249,9 +255,15 @@ run in `asyncio.run(...)` can build the `Donkey` (or call the module-level `after_model_callback` (see [Native Gemini](#native-gemini)). A cold read on a `Donkey` that resolved only ADK reports `UNOBSERVED`, and `OBSERVED` after a call, for `model()` as well as `gemini()`. -- **Refusals on the `model()` path aren't converted to a typed error by the - SDK.** A refusal surfaces as a LiteLLM error whose `__cause__` chain - contains the `openai` `APIStatusError`; see the +- **Refusals on the `model()` path are typed inside `donkey.run()` and + `@donkey.governed`.** LiteLLM re-raises a gateway refusal as its own error, + such as `litellm.exceptions.APIError`, without the gateway's response + headers. The `llm_client` that `model()` sets marks that error with the + refusal the SDK's transport received for the same call, and the + [typed-refusal bridge](https://docs.donkey-kit.dev/errors.md#typed-refusals-at-the-framework-boundary) + raises `PIIDetected`, `ModelSubstituted` or `GatewayUnavailable` in its + place, with LiteLLM's error on `framework_error`. Outside those scopes, and + without `typed_refusals()`, you get LiteLLM's error unchanged; see the [ADK examples](https://docs.donkey-kit.dev/examples/adk.md). - **`gemini()` needs `google-adk>=2.4`**, the first release whose `Gemini` accepts `client_kwargs`; the `adk` extra declares that floor. On an older diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index 73f912ad..cd402325 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -1273,7 +1273,8 @@ of two ways, depending on the proxy's ingress **Format**: automatically. Supported at `connection_kwargs()`. LiteLLM gets a pre-built OpenAI `client` that sends through the SDK's shared HTTP client, so per-run correlation, retries, spans, `donkey.last_call` and the `jwt`-mode JWT apply - (see [Notes](#notes)). + (see [Notes](#notes)). Inside `donkey.run()` or `@donkey.governed`, a + refusal reaches your code as its typed Donkey error, such as `PIIDetected`. - For `gemini()`: the shared client injected through `HttpOptions.httpx_async_client`, round-trip verified live against a `Format=Gemini` proxy. @@ -1374,6 +1375,11 @@ call, and its default is 2. An `api_base` / [`https://` rule](https://docs.donkey-kit.dev/reference/configuration.md#endpoints-must-use-https), and the adapter builds `client` on that URL. +`model()` also sets ADK's `llm_client` to a `LiteLLMClient` subclass, which is +what lets a refusal arrive typed (see [Notes](#notes)). `connection_kwargs()` +leaves it out, so a `LiteLlm` you build yourself raises LiteLLM's own errors +for a refusal. An `llm_client` you pass to `model()` replaces the SDK's. + ## Native Gemini A proxy provisioned with **Format = Gemini** exposes the native Gemini API at @@ -1503,9 +1509,15 @@ run in `asyncio.run(...)` can build the `Donkey` (or call the module-level `after_model_callback` (see [Native Gemini](#native-gemini)). A cold read on a `Donkey` that resolved only ADK reports `UNOBSERVED`, and `OBSERVED` after a call, for `model()` as well as `gemini()`. -- **Refusals on the `model()` path aren't converted to a typed error by the - SDK.** A refusal surfaces as a LiteLLM error whose `__cause__` chain - contains the `openai` `APIStatusError`; see the +- **Refusals on the `model()` path are typed inside `donkey.run()` and + `@donkey.governed`.** LiteLLM re-raises a gateway refusal as its own error, + such as `litellm.exceptions.APIError`, without the gateway's response + headers. The `llm_client` that `model()` sets marks that error with the + refusal the SDK's transport received for the same call, and the + [typed-refusal bridge](https://docs.donkey-kit.dev/errors.md#typed-refusals-at-the-framework-boundary) + raises `PIIDetected`, `ModelSubstituted` or `GatewayUnavailable` in its + place, with LiteLLM's error on `framework_error`. Outside those scopes, and + without `typed_refusals()`, you get LiteLLM's error unchanged; see the [ADK examples](https://docs.donkey-kit.dev/examples/adk.md). - **`gemini()` needs `google-adk>=2.4`**, the first release whose `Gemini` accepts `client_kwargs`; the `adk` extra declares that floor. On an older @@ -2902,10 +2914,9 @@ in a group before matching. On Python 3.10, which has no builtin `ExceptionGroup`, the bridge recognises the `exceptiongroup` backport's groups that anyio raises. - The bridge only sees calls that went through the SDK's transport. ADK's - `model()` (LiteLLM) and CrewAI own their transport, so their errors pass - through untyped. These are the same - [conformance exemptions](https://docs.donkey-kit.dev/testing.md#exemptions) as their correlation ids. + The bridge only sees calls that went through the SDK's transport. CrewAI + owns its transport, so its errors pass through untyped. This is the same + [conformance exemption](https://docs.donkey-kit.dev/testing.md#exemptions) as its correlation id. ### Classifying a response yourself @@ -6013,7 +6024,7 @@ headers go on the wire. An `OpenAILike` for `complete()` and `chat()`; typed refusals. - A `LiteLlm` model in an `InMemoryRunner` — and why its refusals are not typed. + A `LiteLlm` model in an `InMemoryRunner`, and a refusal outside `donkey.run()`. The native client on `/v1/messages`, `last_call` and simulated refusals. @@ -9422,9 +9433,9 @@ Source: https://docs.donkey-kit.dev/examples/adk.md Google ADK with `donkey.adk.model("…")`, a `LiteLlm` model. LiteLLM calls the proxy's `/chat/completions` route. It sends through the SDK's shared HTTP client, so the credentials and the run id -go on the wire, but LiteLLM raises its own errors, so the SDK does not convert -a refusal to a **typed error** here; the `openai` error is in the `__cause__` -chain. Script 02 exists to show that gap. +go on the wire. LiteLLM raises its own errors, so a refusal becomes a **typed +error** only inside `donkey.run()` or `@donkey.governed`. Script 02 calls the +agent outside them, so it shows LiteLLM's own error. Both scripts need a live gateway; there is no offline ADK script. @@ -9467,7 +9478,7 @@ in an `after_model_callback` instead, as shown under [Native Gemini](https://docs.donkey-kit.dev/frameworks/adk.md#native-gemini). LiteLLM also logs a provider-list banner, which is harmless. -## 02 — A refusal that is not typed +## 02 — A refusal outside `donkey.run()` ```bash python "demos/human-made/adk/02 - refusal-live.py" @@ -9475,7 +9486,8 @@ python "demos/human-made/adk/02 - refusal-live.py" A PII prompt against a proxy with the PII policy applied. LiteLLM keeps the status code and the message but drops the response headers, so `classify()` -has nothing to read. The script therefore catches LiteLLM's own error: +has nothing to read. The script makes the call outside `donkey.run()`, so it +catches LiteLLM's own error: ```python try: @@ -9490,12 +9502,24 @@ except litellm.exceptions.APIError as err: should see:** `APIError 403` and the first line of the proxy's message — **not** `PIIDetected`. Without the policy it prints `NO REFUSAL`. - If you need typed refusals with ADK, use - `donkey.adk.gemini("gemini-2.5-flash")` on a `Format=Gemini` proxy and - `classify()` its error (see [Native Gemini](https://docs.donkey-kit.dev/frameworks/adk.md#native-gemini)). - Otherwise prefer a framework path where the SDK owns the transport, such as - [OpenAI](https://docs.donkey-kit.dev/examples/openai.md) or [LangGraph](https://docs.donkey-kit.dev/examples/langgraph.md). A `404` - here means the proxy's upstream has no `/chat/completions` route. +Wrap the same call in `donkey.run()` and the refusal reaches your code as +`PIIDetected`, with LiteLLM's `APIError` on `framework_error`: + +```python +async def main(): + async with donkey.run(): + await InMemoryRunner(agent=agent).run_debug(PII_PROMPT, quiet=True) + +try: + asyncio.run(main()) +except PIIDetected as err: + print(type(err).__name__, type(err.framework_error).__name__) +``` + + Typed refusals on `model()` need the `LiteLlm` that `donkey.adk.model()` + returns. One you build from `connection_kwargs()` raises LiteLLM's errors + even inside `donkey.run()` (see [Google ADK](https://docs.donkey-kit.dev/frameworks/adk.md#manual-equivalent)). + A `404` here means the proxy's upstream has no `/chat/completions` route. **Learn more:** [Google ADK](https://docs.donkey-kit.dev/frameworks/adk.md) diff --git a/website/public/llms.txt b/website/public/llms.txt index 75ced2f5..72e4f5cb 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -84,7 +84,7 @@ - [Strands](https://docs.donkey-kit.dev/examples/strands.md): Four Strands Agents scripts — OpenAIModel on a pre-built governed client, the run id reaching a tool, typed refusals simulated and live. - [CrewAI](https://docs.donkey-kit.dev/examples/crewai.md): Two CrewAI scripts — donkey.crewai.llm() for a direct call and a one-agent crew, and typed refusals from CrewAI's native OpenAI provider. - [LlamaIndex](https://docs.donkey-kit.dev/examples/llamaindex.md): Two LlamaIndex scripts — an OpenAILike from donkey.llamaindex.llm() for complete() and chat(), and typed refusals from its unchanged openai errors. -- [Google ADK](https://docs.donkey-kit.dev/examples/adk.md): Two Google ADK scripts — a LiteLlm model from donkey.adk.model() in an InMemoryRunner, and why a PII refusal is not typed on the LiteLLM path. +- [Google ADK](https://docs.donkey-kit.dev/examples/adk.md): Two Google ADK scripts — a LiteLlm model from donkey.adk.model() in an InMemoryRunner, and a PII refusal raised outside donkey.run(), where it stays LiteLLM's error. - [Anthropic](https://docs.donkey-kit.dev/examples/anthropic.md): Two scripts for the native anthropic client from donkey.anthropic.client() — /v1/messages on a Format=Anthropic proxy with last_call, and typed refusals simulated in-process. - [Gemini](https://docs.donkey-kit.dev/examples/gemini.md): Two plain-httpx scripts against a Format=Gemini proxy — native generateContent, and an OpenAI-shaped request classified as UpstreamRequestError. From 504a19480dcd76210670583eb4615a22ff4c97d7 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:21:52 +0200 Subject: [PATCH 05/34] feat(site): add baseline SEO and share metadata (#1078) Closes #799 --- website/app/[[...mdxPath]]/page.tsx | 3 +- website/app/icon.png | Bin 0 -> 29580 bytes website/app/layout.tsx | 9 ++++- website/app/opengraph-image.jpg | Bin 0 -> 186724 bytes website/app/robots.ts | 11 ++++++ website/app/sitemap.ts | 16 +++++++++ website/lib/site.mjs | 53 ++++++++++++++++++++++++++++ website/lib/site.test.mjs | 44 +++++++++++++++++++++++ website/package.json | 2 +- 9 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 website/app/icon.png create mode 100644 website/app/opengraph-image.jpg create mode 100644 website/app/robots.ts create mode 100644 website/app/sitemap.ts create mode 100644 website/lib/site.mjs create mode 100644 website/lib/site.test.mjs diff --git a/website/app/[[...mdxPath]]/page.tsx b/website/app/[[...mdxPath]]/page.tsx index b7ee963d..0f6802aa 100644 --- a/website/app/[[...mdxPath]]/page.tsx +++ b/website/app/[[...mdxPath]]/page.tsx @@ -1,4 +1,5 @@ import { generateStaticParamsFor, importPage } from 'nextra/pages' +import { pageMetadata } from '../../lib/site.mjs' import { useMDXComponents as getMDXComponents } from '../../mdx-components' // Catch-all route that renders every .mdx under content/. generateStaticParams @@ -13,7 +14,7 @@ type PageProps = { export async function generateMetadata(props: PageProps) { const params = await props.params const { metadata } = await importPage(params.mdxPath) - return metadata + return pageMetadata(metadata, params.mdxPath) } const Wrapper = getMDXComponents().wrapper diff --git a/website/app/icon.png b/website/app/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cb6a8d8fd007e84b262b3d15bd832322362112c9 GIT binary patch literal 29580 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 z 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 Hu0mjf$N{g& literal 0 HcmV?d00001 diff --git a/website/app/layout.tsx b/website/app/layout.tsx index f8bb5cff..68da6032 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -6,9 +6,9 @@ import { getPageMap } from 'nextra/page-map' import 'nextra-theme-docs/style.css' import { cloudflareBeacon } from '../lib/analytics.mjs' import { EDIT_REF, REPO_URL } from '../lib/repo.mjs' +import { OG_IMAGE, SITE_NAME, siteBase } from '../lib/site.mjs' import '../styles/globals.css' -const SITE_NAME = 'Donkey Development Kit' const SITE_DESCRIPTION = 'Governed by the gateway, understood by your code. An open-source SDK that brings Agent Fabric and Omni Gateway awareness into your agent framework.' @@ -34,15 +34,22 @@ const play = Play({ // come from each page's frontmatter, resolved by generateMetadata in the // [[...mdxPath]] route. export const metadata: Metadata = { + // Resolves canonical / og:url / og:image to absolute URLs. The image itself + // comes from the app/opengraph-image.jpg and app/icon.png file conventions. + metadataBase: new URL(siteBase()), title: { default: SITE_NAME, template: `%s — ${SITE_NAME}`, }, description: SITE_DESCRIPTION, openGraph: { + type: 'website', + siteName: SITE_NAME, title: SITE_NAME, description: SITE_DESCRIPTION, + images: [OG_IMAGE], }, + twitter: { card: 'summary_large_image', images: [OG_IMAGE.url] }, } const navbar = ( diff --git a/website/app/opengraph-image.jpg b/website/app/opengraph-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ebd73550a5d578212cee944fc1ebe8d97c075317 GIT binary patch literal 186724 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%0AYP5x`qTAtXk`8sEOf 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 h6C46L&#h$3_pN@b{OeIa { + const params = (await generateStaticParamsFor('mdxPath')()) as { mdxPath?: string[] }[] + const base = siteBase() + return params + .map(({ mdxPath }) => pagePath(mdxPath)) + .sort() + .map((path) => ({ url: new URL(path.slice(1), base).toString() })) +} diff --git a/website/lib/site.mjs b/website/lib/site.mjs new file mode 100644 index 00000000..27772ce3 --- /dev/null +++ b/website/lib/site.mjs @@ -0,0 +1,53 @@ +export const SITE_NAME = 'Donkey Development Kit' + +// The canonical origin of the deployed docs (public/CNAME). Overridable for +// previews and forks; the optional DOCS_BASE_PATH sub-path (next.config.mjs) +// is appended so absolute URLs match where the export is actually served. +export const SITE_ORIGIN = 'https://docs.donkey-kit.dev' + +export function siteBase(origin = process.env.NEXT_PUBLIC_SITE_URL, basePath = process.env.DOCS_BASE_PATH) { + const host = (origin || SITE_ORIGIN).replace(/\/+$/, '') + const prefix = (basePath ?? '').replace(/^\/*/, '/').replace(/\/+$/, '') + return `${host}${prefix === '/' ? '' : prefix}/` +} + +// Site-relative route for a catch-all `mdxPath` (Next joins it onto the +// metadataBase path). The root page is `/`; other pages have no trailing slash, +// matching the `.html` files Next's static export writes. +export function pagePath(mdxPath) { + const segments = (mdxPath ?? []).filter(Boolean) + return segments.length ? `/${segments.join('/')}` : '/' +} + +// Served from app/opengraph-image.jpg (Next's file convention, static export). +// Page-level `openGraph` replaces the layout's wholesale, so it is restated. +export const OG_IMAGE = { url: '/opengraph-image.jpg', width: 1200, height: 675, alt: SITE_NAME } + +// Per-page metadata from a Nextra page's own `metadata` (frontmatter title and +// description). Every page gets a unique title, canonical URL and og:*/twitter:* +// tags; the root layout only supplies the site-wide defaults. +export function pageMetadata(metadata, mdxPath) { + const isRoot = !(mdxPath ?? []).length + let title = typeof metadata.title === 'string' ? metadata.title : undefined + // content/examples/ reuses the heading of content/frameworks/ (and + // budget, ...), which would give two pages the same title. + if (title && mdxPath?.[0] === 'examples' && mdxPath.length > 1) title = `Examples: ${title}` + // The home page keeps the bare site name (no "Index — " from the filename). + const fullTitle = isRoot || !title ? SITE_NAME : `${title} — ${SITE_NAME}` + const canonical = pagePath(mdxPath) + const description = metadata.description ?? undefined + return { + ...metadata, + title: isRoot ? { absolute: SITE_NAME } : title, + alternates: { canonical }, + openGraph: { + type: 'website', + siteName: SITE_NAME, + url: canonical, + title: fullTitle, + description, + images: [OG_IMAGE], + }, + twitter: { card: 'summary_large_image', title: fullTitle, description, images: [OG_IMAGE.url] }, + } +} diff --git a/website/lib/site.test.mjs b/website/lib/site.test.mjs new file mode 100644 index 00000000..0ad90f07 --- /dev/null +++ b/website/lib/site.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { SITE_NAME, SITE_ORIGIN, pageMetadata, pagePath, siteBase } from './site.mjs' + +test('siteBase defaults to the custom domain and always ends in a slash', () => { + assert.equal(siteBase(undefined, undefined), `${SITE_ORIGIN}/`) + assert.equal(siteBase('https://example.test/', ''), 'https://example.test/') +}) + +test('siteBase appends the optional sub-path', () => { + assert.equal(siteBase(undefined, '/ddk'), `${SITE_ORIGIN}/ddk/`) + assert.equal(siteBase(undefined, 'ddk/'), `${SITE_ORIGIN}/ddk/`) +}) + +test('pagePath maps a catch-all segment list to a route', () => { + assert.equal(pagePath(undefined), '/') + assert.equal(pagePath([]), '/') + assert.equal(pagePath(['frameworks', 'crewai']), '/frameworks/crewai') +}) + +test('pageMetadata gives a page its own title, canonical and description', () => { + const m = pageMetadata({ title: 'Quickstart', description: 'Go.' }, ['quickstart']) + assert.equal(m.title, 'Quickstart') + assert.equal(m.alternates.canonical, '/quickstart') + assert.equal(m.openGraph.title, `Quickstart — ${SITE_NAME}`) + assert.equal(m.openGraph.url, '/quickstart') + assert.equal(m.openGraph.description, 'Go.') + assert.equal(m.openGraph.images.length, 1) +}) + +test('the home page keeps the bare site name', () => { + const m = pageMetadata({ title: 'Index' }, undefined) + assert.deepEqual(m.title, { absolute: SITE_NAME }) + assert.equal(m.openGraph.title, SITE_NAME) + assert.equal(m.alternates.canonical, '/') +}) + +test('example pages do not reuse the framework page title', () => { + const fw = pageMetadata({ title: 'CrewAI' }, ['frameworks', 'crewai']) + const ex = pageMetadata({ title: 'CrewAI' }, ['examples', 'crewai']) + assert.notEqual(fw.openGraph.title, ex.openGraph.title) + assert.notEqual(fw.title, ex.title) +}) diff --git a/website/package.json b/website/package.json index a87c8f6f..b45c4293 100644 --- a/website/package.json +++ b/website/package.json @@ -11,7 +11,7 @@ "build": "next build", "postbuild": "pagefind --site out --output-subdir _pagefind", "preview:pages": "node scripts/preview-pages.mjs", - "test": "node --test lib/analytics.test.mjs lib/repo.test.mjs", + "test": "node --test lib/analytics.test.mjs lib/repo.test.mjs lib/site.test.mjs", "start": "next start -p ${PORT:-3000}" }, "dependencies": { From fa46d2248db20fe10207d1183584f888747cd6e2 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:27:56 +0200 Subject: [PATCH 06/34] ci(site): pin manual docs deploys to main (#1069) Closes #771 --- .github/workflows/docs.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6fef3991..5f252aa0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,11 +41,13 @@ jobs: permissions: contents: read steps: - # Scheduled runs start from the default branch (develop); always publish main. + # Scheduled runs start from the default branch (develop) and a manual run + # starts from whatever branch the dispatcher picked; always build main so + # unreleased docs can never be published (#771). - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'schedule' && 'main' || github.ref }} + ref: main - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 20 @@ -67,6 +69,10 @@ jobs: deploy: needs: build + # Belt and braces: only main pushes and the scheduled rebuild may publish. + # Schedule runs report github.ref = default branch, hence the event check. + # A manual dispatch from any branch (e.g. develop) builds but never deploys. + if: github.ref == 'refs/heads/main' || github.event_name == 'schedule' runs-on: ubuntu-latest timeout-minutes: 10 permissions: From 2f11dceca90f2cb4f437ead854b592392ab54985 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:33:32 +0200 Subject: [PATCH 07/34] ci(deps): lock the release toolchain, bot lock bumps, extras-free mypy (#1071) Closes #763 --- .github/workflows/ci.yml | 17 ++- .github/workflows/lock-refresh.yml | 153 +++++++++++++++++++ .github/workflows/publish-pypi.yml | 7 +- .github/workflows/publish-testpypi.yml | 7 +- CONTRIBUTING.md | 32 ++-- docs/adr/0007-dependency-policy.md | 11 ++ docs/adr/0011-nightly-lock-refresh-pr.md | 82 ++++++++++ docs/adr/README.md | 1 + docs/ci.md | 48 +++++- python/constraints/lock-py3.11.txt | 6 + python/constraints/release-py3.11.txt | 42 +++++ python/dependency_allowlist.toml | 18 +++ python/pyproject.toml | 59 +++++-- python/scripts/compile_constraints.py | 48 +++++- python/tests/unit/test_ci_workflows.py | 30 +++- python/tests/unit/test_constraints_lock.py | 58 ++++++- python/tests/unit/test_house_style_config.py | 30 ++++ 17 files changed, 602 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/lock-refresh.yml create mode 100644 docs/adr/0011-nightly-lock-refresh-pr.md create mode 100644 python/constraints/lock-py3.11.txt create mode 100644 python/constraints/release-py3.11.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7192dedc..c7c3388b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,8 @@ # IS a fresh resolve (`all-extra-resolves`, `anthropic-stacks`, `adk-stacks`, # and everything in nightly-matrix.yml) deliberately keep resolving fresh — # they are the canary ADR 0007 rule 4 relies on. Refresh the lock with -# `python scripts/compile_constraints.py` (see CONTRIBUTING.md); keep +# `python scripts/compile_constraints.py` (see CONTRIBUTING.md), or merge the +# PR lock-refresh.yml opens after each green nightly (ADR 0011, #763); keep # `scripts/compile_constraints.py`'s `_COMBOS` in sync with the jobs below — # each entry's `extras`/`dev_group` must match that job's install line exactly. name: CI @@ -89,8 +90,9 @@ jobs: wheel_dir="$(mktemp -d)" venv_dir="$(mktemp -d)" trap 'rm -rf "$wheel_dir" "$venv_dir"' EXIT - pip install build - python -m build --wheel --outdir "$wheel_dir" + # The locked release toolchain, as publish-*.yml uses it (#763). + pip install -c constraints/release-py3.11.txt build hatchling + python -m build --no-isolation --wheel --outdir "$wheel_dir" set -- "$wheel_dir"/*.whl test "$#" -eq 1 # #821: the wheel ships fixtures; none may carry a tenant identifier. @@ -172,10 +174,11 @@ jobs: - name: mypy --strict (blocking, working instruction #8) run: mypy # #721: the examples are user-facing code, so they get the same --strict - # config, one directory per invocation (each is its own program). The - # framework extras are absent here, so their imports resolve through the - # absence-only overrides in pyproject.toml. LangGraph is the exception: it - # is not in those overrides and is checked against the real package in the + # config, one directory per invocation (each is its own program). mypy + # never follows a framework extra (the follow_imports = "skip" override + # in pyproject.toml, #763), installed or not, so this gives the same + # result in a venv with every extra. LangGraph is the exception: it is + # not in that override and is checked against the real package in the # langgraph-demo job below. - name: mypy --strict over python/examples (#721) run: | diff --git a/.github/workflows/lock-refresh.yml b/.github/workflows/lock-refresh.yml new file mode 100644 index 00000000..dfca2167 --- /dev/null +++ b/.github/workflows/lock-refresh.yml @@ -0,0 +1,153 @@ +# Nightly lock refresh (#763, ADR 0011). +# +# PR CI resolves from the committed lock, python/constraints/*.txt (ADR 0007 +# rule 3), so tool and dependency upgrades must arrive as a PR that moves the +# lock, never overnight on their own. This workflow is that PR's author: after +# a green `Nightly matrix` run on `develop` (the fresh-resolve canary, ADR 0007 +# rule 4), it reruns scripts/compile_constraints.py and, when any file changed, +# force-pushes the result to the `bot/lock-refresh` branch and opens (or +# updates) ONE PR to `develop`. The bump PR's own CI is the gate: the nightly +# proves the newest releases work, the PR proves the exact pins do. +# +# Why not Dependabot or Renovate: either would also bump the `>=` floors in +# pyproject.toml (§8.4 keeps those at the lowest verified versions), and each +# bumps one pin at a time, while every constraints file must stay one +# `uv pip compile` resolution of its combo (see compile_constraints.py). +# +# Token: `secrets.LOCK_REFRESH_TOKEN` when the repository has one (a token +# with contents and pull-requests write on this repo), else `github.token`. +# A PR opened with `github.token` does not trigger `pull_request` workflows, +# so with the fallback a maintainer closes and reopens the PR to run CI. The +# fallback also needs the repository setting that lets GitHub Actions create +# pull requests; without it the PR step fails and the `alert` job files the +# tracking issue. See docs/ci.md, "lock-refresh.yml". +# +# `bot/lock-refresh` belongs to this workflow: it is rebuilt from `develop` +# on every run, so commits pushed to it by hand are overwritten. +# +# Alerting (docs/ci.md convention 4): a failed run opens or updates ONE open +# issue labelled `lock-refresh-failure`, from the `alert` job. +# +# Security: no PR code runs here. `workflow_run` fires only for `Nightly +# matrix`, which has no pull_request trigger, and the job checks out `develop` +# itself. That is why zizmor's dangerous-triggers audit is waived on the +# trigger below (#757, as in pr-labels.yml): the risk it flags is running +# untrusted code with this token. Event values reach `run:` bodies through +# env:, never interpolated. The checkout persists no credentials (docs/ci.md +# convention 7), so the push passes the token explicitly through env:. + +name: Lock refresh + +on: + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["Nightly matrix"] + types: [completed] + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: lock-refresh + cancel-in-progress: false + +defaults: + run: + working-directory: python + +jobs: + refresh: + # A manual run, or a green nightly on this repository's develop branch. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'develop' && + github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + pull-requests: write + env: + BRANCH: bot/lock-refresh + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: develop + # No token is left in .git/config (docs/ci.md convention 7); the push + # below passes the token explicitly. + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + # The refresh tool comes from its own lock (combo "lock"). + - run: python -m pip install --upgrade pip && pip install -c constraints/lock-py3.11.txt uv + - name: Recompile every constraints file + run: python scripts/compile_constraints.py + - name: Commit, push and open or update the lock-refresh PR + env: + GH_TOKEN: ${{ secrets.LOCK_REFRESH_TOKEN || github.token }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [ -z "$(git status --porcelain -- constraints)" ]; then + echo "The lock is current; nothing to refresh." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -C "$BRANCH" + git add constraints + git commit -q -m "chore(deps): refresh the PR CI lock" \ + -m "Recompiled by lock-refresh.yml after a green nightly: ${RUN_URL}" + # The checkout persisted no credentials, so authenticate the push with + # the token from env: (never interpolated into this script). + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$BRANCH" + body="Recompiled \`python/constraints/\` with \`python scripts/compile_constraints.py\` after a green nightly (#763, ADR 0011): ${RUN_URL} + + Merge when CI is green. A red run here is an upstream change the lock cannot take yet: fix the code or wait, never add a ceiling (§8.4). If CI did not start, close and reopen this PR (docs/ci.md, \`lock-refresh.yml\`)." + existing=$(gh pr list --repo "$REPO" --head "$BRANCH" --base develop --state open \ + --json number --jq '.[0].number // empty') + if [ -n "$existing" ]; then + gh pr edit "$existing" --repo "$REPO" --body "$body" + else + gh pr create --repo "$REPO" --base develop --head "$BRANCH" \ + --title "chore(deps): refresh the PR CI lock" --body "$body" + fi + + alert: + # docs/ci.md convention 4: a failed refresh must reach a human. Same + # open-or-update pattern as nightly-matrix.yml's alert job (#755). + if: failure() + needs: [refresh] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + issues: write + defaults: + run: + # No checkout here, so python/ does not exist. + working-directory: . + steps: + - name: Open or update the lock-refresh-failure tracking issue + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + # `gh issue create --label` fails when the label does not exist. + gh label create lock-refresh-failure --repo "$REPO" --force \ + --color B60205 --description "The nightly lock refresh failed (#763)" + existing=$(gh issue list --repo "$REPO" --label lock-refresh-failure --state open \ + --json number --jq '.[0].number // empty') + body="The nightly lock refresh failed: ${RUN_URL} + + PR CI keeps resolving from the last committed lock until this is fixed (docs/ci.md, \`lock-refresh.yml\`)." + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$REPO" --body "$body" + else + gh issue create --repo "$REPO" --title "Nightly lock refresh failing" \ + --label lock-refresh-failure --body "$body" + fi diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 9aa50e54..0fc233a5 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -32,9 +32,12 @@ jobs: with: python-version: "3.11" - name: Build + # The release toolchain comes from the lock, and --no-isolation builds + # with that hatchling instead of the newest one on PyPI (#763). Refresh + # with scripts/compile_constraints.py (combo "release"). run: | - pip install build twine - python -m build + pip install -c constraints/release-py3.11.txt build twine hatchling + python -m build --no-isolation - name: twine check (PyPI long description renders cleanly) run: twine check dist/* - name: no tenant identifiers or live platform hosts in the built dists (#821) diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 4325a7ba..a12a5a86 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -31,9 +31,12 @@ jobs: with: python-version: "3.11" - name: Build + # The release toolchain comes from the lock, and --no-isolation builds + # with that hatchling instead of the newest one on PyPI (#763). Refresh + # with scripts/compile_constraints.py (combo "release"). run: | - pip install build twine - python -m build + pip install -c constraints/release-py3.11.txt build twine hatchling + python -m build --no-isolation - name: twine check (PyPI long description renders cleanly) run: twine check dist/* - name: no tenant identifiers or live platform hosts in the built dists (#821) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9ab4384..e7ac3ac0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,7 +181,10 @@ find yourself on `main` about to start work, `git checkout develop` first. **Use a worktree when there's any chance of a parallel session** (another editor window, a running dev server, a `pytest --looponfail` holding files): one issue = one branch = one worktree. A fresh worktree has no installed venv/extras — run -`pip install -e ".[llm,cli]" --group dev` in its `python/` before testing. +`pip install -e ".[llm,cli]" --group dev -c constraints/typecheck-and-lint-py3.11.txt` +in its `python/` before testing. On Python 3.11 that installs the same pinned +versions as the `typecheck-and-lint` job, so `mypy`, `ruff` and `lint-imports` +give CI's result (#763). **No workarounds for prerequisites.** If work on issue #N turns out to need an out-of-scope change first (a missing `core/` primitive, a verification unblock), @@ -606,7 +609,8 @@ failure — don't "fix" the script to make a genuinely-blocked adapter pass. ```bash # from python/ -pip install -e ".[llm,cli]" --group dev # what CI installs (pip 25.1+) +pip install -e ".[llm,cli]" --group dev -c constraints/typecheck-and-lint-py3.11.txt + # the typecheck-and-lint job's install (Python 3.11, pip 25.1+) pytest -q # full suite pytest -q tests/unit # unit only (the base-only CI job) pytest -q -m local_gateway # opt-in local-gateway tests @@ -629,8 +633,13 @@ build plan has the rationale behind each rule: `X | None` over `Optional[X]` (ruff `UP` rewrites the old form), and put `from __future__ import annotations` at the top of every module (ruff `I002`). Don't silence a real signature mismatch with an unexplained `# type: ignore` - — the single `[[tool.mypy.overrides]]` block already handles optional/absent - framework deps. + — the `follow_imports = "skip"` override in `pyproject.toml` already makes + every optional framework extra `Any`, whether or not it is installed, so + `mypy` gives the same result in a `.[llm,cli]` venv and in one with every + extra (#763). The adapters' real framework signatures are checked by the + `adapter-contract` jobs and `scripts/verify_frameworks.py`, not by mypy. + A new extra's import module joins that override in the same PR + (`tests/unit/test_house_style_config.py` checks it). - **Framework-free core & lazy imports.** `core/` depends on **httpx only** (the build plan allows pydantic too, but core imports none) — no agent framework, ever. Adapters import their framework **lazily, @@ -680,11 +689,16 @@ build plan has the rationale behind each rule: merging to "simplify" the file count). The jobs that are deliberately a fresh resolve (`all-extra-resolves`, `adk-stacks`, `anthropic-stacks`, and everything in `.github/workflows/nightly-matrix.yml`, the canary ADR 0007 - rule 4 relies on) have no `-c` flag and must keep none. **Refresh the lock** - after changing a dependency, or on whatever cadence a maintainer judges - useful — there is no scheduled job that does this automatically (opening a - PR from a scheduled workflow needs write-access credentials this repo - doesn't grant one, so it stays a manual step): + rule 4 relies on) have no `-c` flag and must keep none. The lock also pins + the release toolchain (`build`, `twine`, `hatchling`, the `release` group) + and `uv` (the `lock` group) as tool-only combos; every `python -m build` + step installs from `constraints/release-py3.11.txt` and builds with + `--no-isolation` (#763). After each green nightly on `develop`, + `.github/workflows/lock-refresh.yml` recompiles every file and opens or + updates one `chore(deps): refresh the PR CI lock` PR + ([ADR 0011](docs/adr/0011-nightly-lock-refresh-pr.md), + [`docs/ci.md`](docs/ci.md#lock-refreshyml-the-lock-bump)). **Refresh the + lock by hand** in the PR that changes a dependency: ```bash cd python python scripts/compile_constraints.py # needs `uv` on PATH (dev-time only; CI still uses plain pip) diff --git a/docs/adr/0007-dependency-policy.md b/docs/adr/0007-dependency-policy.md index 8508047f..d2089d54 100644 --- a/docs/adr/0007-dependency-policy.md +++ b/docs/adr/0007-dependency-policy.md @@ -4,6 +4,8 @@ - **Date:** 2026-10-03 (rule 3 implemented 2026-10-05, #948) - **Issue:** #731 (part of #707). The lowest-direct floor job is #769; locking PR CI was #948 (see Consequences). +- **Amended by:** [ADR 0011](0011-nightly-lock-refresh-pr.md) (2026-10-10, + #763), a nightly job now opens the lock-refresh PR. ## Context @@ -116,3 +118,12 @@ encoded as pins. What the repo does today: conformance-tested (`BG §1.8`, ADR 0004's roster-consistency test). - `CONTRIBUTING.md` §3 (the "Extras are floors, never ceilings" rule) cites this ADR. + +## Amendment (2026-10-10, ADR 0011, #763) + +Rule 3's refresh clause no longer holds: `.github/workflows/lock-refresh.yml` +reruns `python scripts/compile_constraints.py` after every green nightly on +`develop` and opens or updates one PR with the result +([ADR 0011](0011-nightly-lock-refresh-pr.md)). A maintainer can still refresh +by hand. The lock now also covers the release toolchain (`build`, `twine`, +`hatchling`) and `uv`, as tool-only combos. diff --git a/docs/adr/0011-nightly-lock-refresh-pr.md b/docs/adr/0011-nightly-lock-refresh-pr.md new file mode 100644 index 00000000..4c79c3c6 --- /dev/null +++ b/docs/adr/0011-nightly-lock-refresh-pr.md @@ -0,0 +1,82 @@ +# ADR 0011: A green nightly opens the lock-refresh PR, and the release toolchain is locked too + +- **Status:** Accepted +- **Date:** 2026-10-10 +- **Issue:** #763 (part of #711). Amends [ADR 0007](0007-dependency-policy.md) + rule 3's refresh clause. + +## Context + +[ADR 0007](0007-dependency-policy.md) rule 3 put PR CI on a committed lock, +`python/constraints/-py3.NN.txt`, generated by +`python scripts/compile_constraints.py`. It left two gaps that #763 tracks: + +- **Nothing moves the lock.** Rule 3 says the lock is refreshed by a PR a + maintainer opens by hand, and that no scheduled job opens it, because a + workflow needs write access to push a branch and open a PR. In practice the + lock only moves when someone remembers, so the PR gate drifts away from what + users install, while the nightly (rule 4) already proves every night that + the newest releases work. +- **The release toolchain is not locked.** `publish-pypi.yml`, + `publish-testpypi.yml` and `ci.yml`'s `base-only` job run + `pip install build twine` and `python -m build`. That builds in an isolated + environment with the newest `hatchling` on PyPI, so the backend that builds + an immutable PyPI release is whatever was newest that day. `uv`, which + `compile_constraints.py` drives, isn't pinned either. + +## Decision + +1. **A green nightly opens the lock-refresh PR.** `.github/workflows/lock-refresh.yml` + runs after every successful `Nightly matrix` run on `develop`, and on + manual dispatch. It reruns `python scripts/compile_constraints.py` on + `develop` and, when any file changed, force-pushes the result to the + `bot/lock-refresh` branch and opens or updates one PR to `develop`. The + bump PR's own CI is the gate; a maintainer merges it like any other PR. + This replaces rule 3's "there is no scheduled job to open that PR"; the + rest of rule 3 stands, and a maintainer can still refresh by hand. +2. **The workflow's write scopes are its own.** Only the job that pushes has + `contents: write` and `pull-requests: write`; its `alert` job alone has + `issues: write` and opens one `lock-refresh-failure` issue on failure + (docs/ci.md convention 4). `tests/unit/test_ci_workflows.py` holds both. +3. **The release toolchain resolves from the lock.** The `release` dependency + group (`build`, `twine`, `hatchling`) and the `lock` group (`uv`) get + tool-only combos in `_COMBOS`, compiled to `constraints/release-py3.11.txt` + and `constraints/lock-py3.11.txt`. Every `python -m build` step installs + from the release file and passes `--no-isolation`, so it builds with the + locked `hatchling`. `tests/unit/test_constraints_lock.py` fails a build + step that does neither. +4. **`[build-system]` keeps a floor.** `requires = ["hatchling>=1.27"]` stays + a floor (`§8.4`): it is what a user's build from the sdist resolves, and + the locked version is the publish job's choice, not theirs. + +### Alternatives considered + +- **Dependabot or Renovate.** Rejected. Both treat `pyproject.toml` as a + manifest and would raise the `>=` floors that rule 1 keeps at the lowest + verified version. Both bump one pin at a time, while each constraints file + must stay one `uv pip compile` resolution of its combo (pins bumped + independently can be unsatisfiable together, which is what broke the first, + merged version of the lock). Dependabot also doesn't recognise + `python/constraints/*.txt` as a lock it can regenerate. +- **A separate cron.** Rejected: following the nightly means the refresh runs + only on a day the newest releases passed, so a red upstream day doesn't + produce a bump PR that is red for the same reason. +- **Pin `hatchling` in `[build-system]`.** Rejected by rule 2 of ADR 0007 and + `§8.4`: a ceiling or `==` there binds every sdist consumer. + +## Consequences + +- A PR opened with the default `github.token` doesn't trigger `pull_request` + workflows, and the repository must allow GitHub Actions to create pull + requests. The workflow uses a `LOCK_REFRESH_TOKEN` secret when one exists; + without it, a maintainer closes and reopens the bump PR to start CI. + Both are repository settings, recorded in `docs/ci.md`. +- `bot/lock-refresh` belongs to the workflow and is rebuilt from `develop` on + every run. +- A bump PR whose CI is red means the lock can't move yet. The fix is the + same as for a red nightly (rule 4): adapt the code or wait, never add a + ceiling. +- The `mypy` half of #763 needs no ADR: `follow_imports = "skip"` for the + optional extras is a type-checking setting, not dependency policy. + `tests/unit/test_house_style_config.py` keeps every extra the typecheck job + doesn't install in that override. diff --git a/docs/adr/README.md b/docs/adr/README.md index f0b07d90..78a94d91 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -82,3 +82,4 @@ is not rewritten. | [0008](0008-legacy-quarantine-and-cli-home.md) | Quarantine the legacy provisioning code and give the CLI its own package | Accepted | #730 | | [0009](0009-sans-io-transport.md) | A sans-IO transport policy, and no default retry of model POSTs on 502/504 | Accepted | #728 | | [0010](0010-no-hidden-global-side-effects.md) | No hidden global side effects: the OTel provider | Accepted | #732 | +| [0011](0011-nightly-lock-refresh-pr.md) | A green nightly opens the lock-refresh PR, and the release toolchain is locked too | Accepted | #763 | diff --git a/docs/ci.md b/docs/ci.md index 6b07b91e..f5309a3d 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -19,6 +19,7 @@ policy behind the locks and the nightly run, see - [The signals](#the-signals) - [ci.yml: the PR gate](#ciyml-the-pr-gate) - [nightly-matrix.yml: the canary](#nightly-matrixyml-the-canary) + - [lock-refresh.yml: the lock bump](#lock-refreshyml-the-lock-bump) - [live-contract-check.yml: the gateway contract](#live-contract-checkyml-the-gateway-contract) - [Scenario maps](#scenario-maps) - [Feature or fix (normal path)](#feature-or-fix-normal-path) @@ -48,6 +49,7 @@ should be split. | --- | --- | --- | | Did this change break something? | `ci.yml` (PR-gating jobs, resolving from the committed lock) | Every PR; pushes to `main` and `develop` | | Did an upstream release break us? | `nightly-matrix.yml`, plus the fresh-resolve jobs in `ci.yml` (`all-extra-resolves`, `anthropic-stacks`, `adk-stacks`) | Daily 06:00 UTC; every PR for the `ci.yml` jobs | +| Can the lock move to the newest releases? | `lock-refresh.yml` (opens one bump PR; its CI answers) | After each green nightly on `develop`; manual | | Did the live gateway's contract drift? | `live-contract-check.yml` (the `openai-model-routing` proxy only) | Mondays 07:00 UTC | | Did a secret get committed? | `secret-scan.yml` (gitleaks over the full history) | Every PR; pushes to `main` and `develop` | | Is the published site current? | `docs.yml` (build and deploy to GitHub Pages) | Pushes to `main` under `website/**`; every 6 hours; manual | @@ -100,6 +102,36 @@ it by adapting the code or by moving the lock, never by adding a ceiling `nightly-failure`, or comments on it if it is already open (#755). Close that issue once the nightly is green again. +### `lock-refresh.yml`: the lock bump + +After every green nightly on `develop`, and on manual dispatch, this workflow +reruns `python scripts/compile_constraints.py` on `develop` (#763, +[ADR 0011](adr/0011-nightly-lock-refresh-pr.md)). When any file under +`python/constraints/` changed, it force-pushes the result to +`bot/lock-refresh` and opens one PR to `develop`, or updates the body of the +one already open. Merge it once its CI is green. A red bump PR is an upstream +change the lock can't take yet: fix the code, or leave the PR open until the +next refresh replaces it. Never add a ceiling (`§8.4`). The branch belongs to +the workflow, so don't push to it by hand. A failed run opens or updates one +issue labelled `lock-refresh-failure`. + +**One-time setup.** A PR opened with the default `github.token` doesn't start +`pull_request` workflows, so its CI never runs on its own. A maintainer with +admin rights on the repository picks one: + +1. Add a `LOCK_REFRESH_TOKEN` repository secret: a fine-grained token, or a + GitHub App installation token, with **Contents** and **Pull requests** + write access to this repository only. The workflow uses it when it + exists, and CI runs on the bump PR like on any other. +2. Or, with no secret, allow the default token to open PRs: **Settings → + Actions → General → Workflow permissions → Allow GitHub Actions to create + and approve pull requests**. Then close and reopen each bump PR to start + its CI. + +Without either, the PR step fails and the run files the +`lock-refresh-failure` issue. Check the setup with +`gh workflow run lock-refresh.yml --ref develop`. + ### `live-contract-check.yml`: the gateway contract Every Monday this workflow runs the `sandbox` suite (`tests/sandbox/`) against @@ -285,8 +317,15 @@ extras, dev group and Python combination, never merged across combinations `python scripts/compile_constraints.py`, and keep its `_COMBOS` in sync with the install lines in `ci.yml`. -Still open under #763: pinning the build backend and the release tools, -Dependabot-driven lock bumps, and making `mypy` independent of which extras are +The lock also covers tools (#763). The `release` dependency group (`build`, +`twine`, `hatchling`) and the `lock` group (`uv`) compile to +`constraints/release-py3.11.txt` and `constraints/lock-py3.11.txt`. Every +`python -m build` step installs from the release file and builds with +`--no-isolation`, so a published dist is built by the locked `hatchling`. +`lock-refresh.yml` opens the PR that moves the lock after each green nightly +([ADR 0011](adr/0011-nightly-lock-refresh-pr.md); Dependabot would raise the +floors and bump pins one at a time). `mypy` never follows an optional extra +(`follow_imports = "skip"`), so its result doesn't depend on which extras are installed. Still open under #769: a lowest-direct job that keeps the declared floors honest, an `openai<2` leg, and Python 3.13/3.14 in the matrix. @@ -311,8 +350,9 @@ promotion. `secret-scan.yml` covers `develop` too. There is no merge queue, so Each cron workflow ends with an `if: failure()` job or step that opens or updates one labelled tracking issue (never one issue per run), using `issues: write` on that job only. `live-contract-check.yml` does this with the -`live-contract-drift` label (#753), and `nightly-matrix.yml` with the -`nightly-failure` label (#755). `docs.yml`'s cron still needs the same step. +`live-contract-drift` label (#753), `nightly-matrix.yml` with the +`nightly-failure` label (#755), and `lock-refresh.yml`, which follows the +nightly, with the `lock-refresh-failure` label (#763). `docs.yml`'s cron still needs the same step. ### 5. One task runner mirrors CI. **Target** (#765) diff --git a/python/constraints/lock-py3.11.txt b/python/constraints/lock-py3.11.txt new file mode 100644 index 00000000..1ac24d66 --- /dev/null +++ b/python/constraints/lock-py3.11.txt @@ -0,0 +1,6 @@ +# GENERATED FILE — do not edit by hand. +# Refresh with: python scripts/compile_constraints.py (combo='lock', python 3.11) +# Covers workflow job(s): lock-refresh.yml +# See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3. +# +uv==0.13.0 diff --git a/python/constraints/release-py3.11.txt b/python/constraints/release-py3.11.txt new file mode 100644 index 00000000..56942187 --- /dev/null +++ b/python/constraints/release-py3.11.txt @@ -0,0 +1,42 @@ +# GENERATED FILE — do not edit by hand. +# Refresh with: python scripts/compile_constraints.py (combo='release', python 3.11) +# Covers workflow job(s): publish-pypi.yml + publish-testpypi.yml build, base-only wheel build +# See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3. +# +backports-tarfile==1.2.0 +build==1.6.1 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.5.2 +cryptography==50.0.2 +docutils==0.23 +hatchling==1.32.4 +id==1.6.1 +idna==3.20 +importlib-metadata==9.0.1 +jaraco-classes==3.4.0 +jaraco-context==6.1.2 +jaraco-functools==4.6.0 +jeepney==0.9.0 +keyring==25.7.0 +markdown-it-py==4.2.0 +mdurl==0.1.2 +more-itertools==11.1.0 +nh3==0.3.7 +packaging==26.3 +pathspec==1.1.1 +pluggy==1.6.0 +pycparser==3.11 +pygments==2.21.0 +pyproject-hooks==1.3.3 +readme-renderer==46.0 +requests==2.34.2 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==15.0.0 +secretstorage==3.5.0 +tomlkit==0.15.1 +trove-classifiers==2026.9.21.13 +twine==7.0.0 +urllib3==2.8.0 +zipp==4.1.1 diff --git a/python/dependency_allowlist.toml b/python/dependency_allowlist.toml index 6a57e828..1e9456f9 100644 --- a/python/dependency_allowlist.toml +++ b/python/dependency_allowlist.toml @@ -161,3 +161,21 @@ reviewed = 2026-10-05 reason = "dev: caps every test at 60s, so a stalled proxy/collector call fails fast instead of hanging CI (#747)." reviewed = 2026-10-05 + +# --- release and lock groups: the locked release toolchain (not published, #763) --- + +[packages.build] +reason = "release: builds the sdist and wheel in publish-pypi.yml, publish-testpypi.yml and ci.yml's base-only job, with --no-isolation against the locked hatchling (#763)." +reviewed = 2026-10-10 + +[packages.twine] +reason = "release: `twine check` on the built dists before upload in both publish workflows (#763)." +reviewed = 2026-10-10 + +[packages.hatchling] +reason = "release: the build backend ([build-system]); listed in the release group so the publish builds use the locked version (#763)." +reviewed = 2026-10-10 + +[packages.uv] +reason = "lock: `uv pip compile` behind scripts/compile_constraints.py, which lock-refresh.yml runs nightly to refresh constraints/ (#763)." +reviewed = 2026-10-10 diff --git a/python/pyproject.toml b/python/pyproject.toml index 130c4cb5..e6a4854e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -172,6 +172,25 @@ dev = [ # instead of hanging CI. "pytest-timeout>=2.3", ] +# The release toolchain (#763). The publish workflows and ci.yml's base-only +# wheel build install these from constraints/release-py3.11.txt and build with +# `python -m build --no-isolation`, so the backend that builds a shipped dist is +# the locked one, not whatever hatchling was newest that day. [build-system] +# above keeps its floor (§8.4): it is what an sdist consumer's build resolves. +release = [ + "build>=1.2.1", + # 6.1 drops pkginfo for packaging's metadata parser, so `twine check` + # accepts the Metadata-Version 2.4 (PEP 639) dists hatchling>=1.27 writes + # whatever pkginfo is installed; 6.0.0 rejects them. + "twine>=6.1", + "hatchling>=1.27", +] +# The lock's own refresh tool (#763): scripts/compile_constraints.py drives +# `uv pip compile`. lock-refresh.yml installs it from constraints/lock-py3.11.txt. +lock = [ + # 0.6.7: the first release whose `uv pip compile` takes `--group`. + "uv>=0.6.7", +] [project.scripts] donkey = "donkey_kit.cli:main" @@ -216,15 +235,21 @@ python_version = "3.10" files = ["src/donkey_kit", "tests/typecheck"] [[tool.mypy.overrides]] -# Optional deps may be absent (CI installs only [llm,cli] + the dev group) or ship no stubs; -# neither should fail the base typecheck. opentelemetry IS typed when the [otel] -# extra is present, so it is listed here for absence only — telemetry.py must not -# carry an inline ignore that goes stale the moment someone installs it. -# tomli is the <3.11 tomllib backfill (core/config.py): mypy targets -# py3.10 so it analyzes the `import tomli` branch, but CI runs on 3.11 where tomli -# is not installed — absence only, same rationale as the framework extras above. +# Optional extras are never followed, installed or not (#763), so `mypy` gives the +# same result in the typecheck job's [llm,cli] + dev venv and in a venv with +# every extra. Before this, mypy followed whatever happened to be installed: a +# venv with a framework extra type-checked the adapters against that release's +# own annotations (and once reached numpy's 3.12-only `type` statements through +# a framework, failing at the 3.10 target), while CI checked them against Any. +# `skip` makes the CI behaviour the only behaviour: each package below is Any +# whether or not it is present, and the adapters' real signatures are checked +# by the adapter-contract jobs and scripts/verify_frameworks.py (§8), not here. +# `follow_imports_for_stubs` extends the skip to packages that ship .pyi files +# (numpy, opentelemetry). tests/unit/test_house_style_config.py keeps every +# optional extra outside [llm,cli] in this list. langgraph itself is NOT listed: +# src/ never imports it, and the langgraph-demo job checks examples/langgraph +# against the real package. module = [ - "tomli", "langchain_openai.*", "langchain_core.*", "opentelemetry.*", @@ -242,8 +267,22 @@ module = [ # public return type is a framework-free Protocol, so no Any leaks out. "starlette.*", "uvicorn.*", - # httpx2 is typed, and arrives with openai>=3 or anthropic>=1.0; listed for - # absence only, under an older openai with no anthropic (#701). + # Never imported by src/, but reachable through a framework's own imports. + "numpy.*", +] +follow_imports = "skip" +follow_imports_for_stubs = true +ignore_missing_imports = true + +[[tool.mypy.overrides]] +# Absence only: these are part of the typecheck environment and typed, so mypy +# follows them when present. tomli is the <3.11 tomllib backfill (core/config.py): +# mypy targets py3.10 so it analyzes the `import tomli` branch, but it may be +# absent on 3.11+. httpx2 arrives with openai>=3 or anthropic>=1.0 and +# tests/typecheck/ checks against its real types; it is absent under an older +# openai with no anthropic (#701). +module = [ + "tomli", "httpx2.*", ] ignore_missing_imports = true diff --git a/python/scripts/compile_constraints.py b/python/scripts/compile_constraints.py index 6f6f4158..d795a6f1 100644 --- a/python/scripts/compile_constraints.py +++ b/python/scripts/compile_constraints.py @@ -65,13 +65,20 @@ @dataclass(frozen=True) class Combo: - """One exact `pip install -e ".[...]" [--group dev]` a PR-gating ci.yml job runs.""" + """One exact install line a workflow job runs: `pip install -e ".[...]" [--group dev]` + for a PR-gating ci.yml job, or the tool-only install of a dependency group.""" name: str extras: tuple[str, ...] dev_group: bool python_versions: tuple[str, ...] - covers: str # which ci.yml job(s), for the comment header + covers: str # which workflow job(s), for the comment header + # Further dependency groups, each passed as `--group ` (#763). + groups: tuple[str, ...] = () + # False for a tool-only lock (the `release` and `lock` groups): the compile + # then covers just the groups, not donkey-kit's own dependencies, because + # the job installs only those tools (`pip install -c build twine`). + project: bool = True def file_name(self, python_version: str) -> str: return f"{self.name}-py{python_version}.txt" @@ -85,6 +92,9 @@ def file_name(self, python_version: str) -> str: # requested, which is exactly how the old merged-file design broke (see the # module docstring). # +# The last two combos lock tools rather than a ci.yml install line (#763): the +# release toolchain (build, twine, hatchling) and the lock's own `uv`. +# # Deliberately NOT covered (ADR 0007 rules 1-2, resolve fresh every run): # all-extra-resolves, anthropic-stacks, adk-stacks, and everything in # nightly-matrix.yml. @@ -187,6 +197,25 @@ def file_name(self, python_version: str) -> str: ("3.12",), "adapter-contract(llamaindex)", ), + # Tool-only locks (#763): no extras, no dev group, no donkey-kit deps. + Combo( + "release", + (), + False, + ("3.11",), + "publish-pypi.yml + publish-testpypi.yml build, base-only wheel build", + groups=("release",), + project=False, + ), + Combo( + "lock", + (), + False, + ("3.11",), + "lock-refresh.yml", + groups=("lock",), + project=False, + ), ) @@ -196,11 +225,10 @@ def _compile_one(combo: Combo, python_version: str, out_file: Path) -> str: Returns the rendered content (header + the compiled pins), so the caller can either write it or diff it against what's committed. """ - cmd = [ - "uv", - "pip", - "compile", - str(_PYTHON_ROOT / "pyproject.toml"), + cmd = ["uv", "pip", "compile"] + if combo.project: + cmd.append(str(_PYTHON_ROOT / "pyproject.toml")) + cmd += [ "--python-version", python_version, "--python-platform", @@ -214,6 +242,9 @@ def _compile_one(combo: Combo, python_version: str, out_file: Path) -> str: cmd.extend(["--extra", extra]) if combo.dev_group: cmd.extend(["--group", "dev"]) + for group in combo.groups: + # Without a SRC_FILE, uv reads the group from ./pyproject.toml (cwd below). + cmd.extend(["--group", group]) try: subprocess.run(cmd, check=True, cwd=_PYTHON_ROOT, capture_output=True, text=True) except subprocess.CalledProcessError as exc: @@ -224,7 +255,8 @@ def _compile_one(combo: Combo, python_version: str, out_file: Path) -> str: "# GENERATED FILE — do not edit by hand.\n" f"# Refresh with: python scripts/compile_constraints.py " f"(combo={combo.name!r}, python {python_version})\n" - "# Covers ci.yml job(s): " + # Tool-only locks serve jobs outside ci.yml too (publish-*.yml, lock-refresh.yml). + f"# Covers {'ci.yml' if combo.project else 'workflow'} job(s): " f"{combo.covers}\n" "# See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3.\n" "#\n" diff --git a/python/tests/unit/test_ci_workflows.py b/python/tests/unit/test_ci_workflows.py index 4da8ac2c..343469fe 100644 --- a/python/tests/unit/test_ci_workflows.py +++ b/python/tests/unit/test_ci_workflows.py @@ -33,7 +33,7 @@ def _needs(job: dict[str, Any]) -> set[str]: @pytest.mark.parametrize( ("workflow", "aggregate"), - [("ci.yml", "ci-ok"), ("nightly-matrix.yml", "alert")], + [("ci.yml", "ci-ok"), ("nightly-matrix.yml", "alert"), ("lock-refresh.yml", "alert")], ) def test_aggregate_job_needs_every_other_job(workflow: str, aggregate: str) -> None: jobs = _workflow(workflow)["jobs"] @@ -60,3 +60,31 @@ def test_nightly_alert_is_the_only_job_that_writes_issues() -> None: assert alert["permissions"] == {"issues": "write"} others = {k: v for k, v in workflow["jobs"].items() if k != "alert"} assert not [k for k, v in others.items() if "permissions" in v] + + +def test_lock_refresh_follows_the_nightly_by_its_display_name() -> None: + # workflow_run matches on the display name, so renaming `Nightly matrix` + # would stop the lock refresh silently (#763). + nightly = _workflow("nightly-matrix.yml")["name"] + triggers = _workflow("lock-refresh.yml")[True] + assert triggers["workflow_run"] == {"workflows": [nightly], "types": ["completed"]} + assert "workflow_dispatch" in triggers + + +def test_lock_refresh_runs_only_after_a_green_nightly_on_develop() -> None: + condition = _workflow("lock-refresh.yml")["jobs"]["refresh"]["if"] + for clause in ( + "github.event.workflow_run.conclusion == 'success'", + "github.event.workflow_run.head_branch == 'develop'", + ): + assert clause in condition + + +def test_lock_refresh_write_scopes_stay_on_their_own_jobs() -> None: + workflow = _workflow("lock-refresh.yml") + assert workflow["permissions"] == {"contents": "read"} + jobs = workflow["jobs"] + assert jobs["refresh"]["permissions"] == {"contents": "write", "pull-requests": "write"} + assert jobs["alert"]["if"] == "failure()" + assert jobs["alert"]["permissions"] == {"issues": "write"} + assert set(jobs) == {"refresh", "alert"} diff --git a/python/tests/unit/test_constraints_lock.py b/python/tests/unit/test_constraints_lock.py index f647ca02..4af2a205 100644 --- a/python/tests/unit/test_constraints_lock.py +++ b/python/tests/unit/test_constraints_lock.py @@ -33,7 +33,9 @@ _PYTHON_ROOT = Path(__file__).resolve().parents[2] _CONSTRAINTS_DIR = _PYTHON_ROOT / "constraints" -_CI_YAML = _PYTHON_ROOT.parent / ".github" / "workflows" / "ci.yml" +_WORKFLOWS_DIR = _PYTHON_ROOT.parent / ".github" / "workflows" +_CI_YAML = _WORKFLOWS_DIR / "ci.yml" +_CONSTRAINT_REF_RE = re.compile(r"constraints/([A-Za-z0-9._-]+\.txt)") _PIN_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==") # requirement_name()/canonicalize() do the exact PEP 503 normalization the @@ -53,8 +55,8 @@ def _extra_names(pyproject: dict[str, Any], extra: str) -> set[str]: return {requirement_name(r) for r in raw} -def _dev_group_names(pyproject: dict[str, Any]) -> set[str]: - raw = [item for item in pyproject["dependency-groups"]["dev"] if isinstance(item, str)] +def _group_names(pyproject: dict[str, Any], group: str) -> set[str]: + raw = [item for item in pyproject["dependency-groups"][group] if isinstance(item, str)] return {requirement_name(r) for r in raw} @@ -64,7 +66,9 @@ def _combo_direct_names(combo: Combo, pyproject: dict[str, Any]) -> set[str]: for extra in combo.extras: names |= _extra_names(pyproject, extra) if combo.dev_group: - names |= _dev_group_names(pyproject) + names |= _group_names(pyproject, "dev") + for group in combo.groups: + names |= _group_names(pyproject, group) return names @@ -174,3 +178,49 @@ def test_adapter_contract_matrix_extras_each_have_their_own_combo() -> None: f"ci.yml adapter-contract matrix has extra {extra!r} but " f"scripts/compile_constraints.py has no Combo named {expected_combo!r}" ) + + +def test_every_constraints_file_a_workflow_names_exists() -> None: + """A `constraints/.txt` a workflow installs from is committed (#763). + + Templated names (`test-py${PYTHON_VERSION}.txt`) are not matched by the + pattern; the per-combo tests above cover those. + """ + for workflow in sorted(_WORKFLOWS_DIR.glob("*.yml")): + for name in _CONSTRAINT_REF_RE.findall(workflow.read_text(encoding="utf-8")): + assert (_CONSTRAINTS_DIR / name).is_file(), ( + f"{workflow.name} installs from constraints/{name}, which is not " + "committed; add its Combo and run `python scripts/compile_constraints.py`" + ) + + +def test_every_dist_build_uses_the_locked_release_toolchain() -> None: + """Each `python -m build` step builds with the locked backend (#763). + + `python -m build` alone builds in an isolated env with whatever hatchling + is newest that day. `--no-isolation` makes it use the hatchling the job + installed from constraints/release-py3.11.txt, and the same step must + install from that file. + """ + seen = 0 + for workflow in sorted(_WORKFLOWS_DIR.glob("*.yml")): + for step in re.split(r"\n\s*- ", workflow.read_text(encoding="utf-8")): + commands = [ + line.strip() + for line in step.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + for build in (c for c in commands if "python -m build" in c): + seen += 1 + assert "--no-isolation" in build, ( + f"{workflow.name}: `{build}` builds in an isolated env, so it " + "ignores the locked hatchling; add --no-isolation" + ) + assert any( + "pip install" in c and "-c constraints/release-py3.11.txt" in c + for c in commands + ), ( + f"{workflow.name}: the step running `{build}` does not install " + "its toolchain with -c constraints/release-py3.11.txt" + ) + assert seen >= 3, "expected the publish-pypi, publish-testpypi and base-only builds" diff --git a/python/tests/unit/test_house_style_config.py b/python/tests/unit/test_house_style_config.py index a564a946..f0b1501b 100644 --- a/python/tests/unit/test_house_style_config.py +++ b/python/tests/unit/test_house_style_config.py @@ -130,6 +130,36 @@ def test_every_published_extra_is_imported_by_src() -> None: assert unused == [], f"extras no module in src/ imports: {unused}" +# The extras the typecheck-and-lint job installs ([llm,cli] + the dev group, +# which carries pytest): mypy checks src/ against their real types. +_TYPECHECKED_EXTRAS = frozenset({"llm", "cli", "test"}) + + +def test_mypy_never_follows_an_optional_extra() -> None: + """mypy gives one result whichever extras are installed (#763). + + Every extra the typecheck job does not install has its import module in + the `follow_imports = "skip"` override, so a venv with `.[all]` checks + src/ exactly as CI's `.[llm,cli]` venv does. Without it, mypy follows an + installed framework into numpy's stubs and fails at the 3.10 target. + """ + overrides = _pyproject()["tool"]["mypy"]["overrides"] + skipped = [o for o in overrides if o.get("follow_imports") == "skip"] + assert len(skipped) == 1, "keep one follow_imports = 'skip' override" + (override,) = skipped + # Without this, packages that ship .pyi stubs (numpy, opentelemetry) are + # still followed. + assert override.get("follow_imports_for_stubs") is True + modules = set(override["module"]) + missing = sorted( + f"{module}.*" + for extra, module in _EXTRA_IMPORTS.items() + if extra not in _TYPECHECKED_EXTRAS and f"{module}.*" not in modules + ) + assert missing == [], f"add to the follow_imports = 'skip' override: {missing}" + assert "numpy.*" in modules, "numpy is reached through the frameworks' own imports" + + def test_all_extra_installs_no_test_runner_or_linter() -> None: """`[all]` is everything a user runs, not contributor tooling (#744).""" extras = _pyproject()["project"]["optional-dependencies"] From 8e895c39097dba05acdebe97eff097203d6a4f8c Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:33:52 +0200 Subject: [PATCH 08/34] ci(release): one version source, tag check, wheel smoke, attestations (#1075) Closes #767 --- .github/workflows/publish-pypi.yml | 40 ++++- .github/workflows/publish-testpypi.yml | 31 +++- docs/ci.md | 24 +-- docs/releasing.md | 114 ++++++++---- python/pyproject.toml | 12 +- python/scripts/check_release_version.py | 125 +++++++++++++ python/scripts/smoke_test_wheel.py | 121 +++++++++++++ python/src/donkey_kit/__init__.py | 3 + python/tests/unit/test_release_version.py | 184 ++++++++++++++++++++ python/tests/unit/test_release_workflows.py | 92 ++++++++++ python/tests/unit/test_smoke_test_wheel.py | 105 +++++++++++ scripts/bump-version.sh | 85 +++++---- 12 files changed, 853 insertions(+), 83 deletions(-) create mode 100644 python/scripts/check_release_version.py create mode 100644 python/scripts/smoke_test_wheel.py create mode 100644 python/tests/unit/test_release_version.py create mode 100644 python/tests/unit/test_release_workflows.py create mode 100644 python/tests/unit/test_smoke_test_wheel.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 0fc233a5..69abd820 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -20,6 +20,25 @@ defaults: working-directory: python jobs: + test: + name: Test suite at the release commit + # #767: a Release can be published on a commit whose CI never went green, + # so the suite runs again here, on the exact commit being uploaded, before + # any upload. Same install and command as ci.yml's `test` job (3.11 leg). + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - run: >- + python -m pip install --upgrade pip && + pip install -e ".[llm,cli,local,otel]" --group dev -c constraints/test-py3.11.txt + - run: pytest -q + build: name: Build sdist + wheel runs-on: ubuntu-latest @@ -40,6 +59,17 @@ jobs: python -m build --no-isolation - name: twine check (PyPI long description renders cleanly) run: twine check dist/* + - name: the tag is v and every dist carries that version (#767) + # The version is declared once, in src/donkey_kit/__init__.py. A final + # (non-pre-release) Release must also carry a final X.Y.Z version: only + # those reach production PyPI. Event values go through env, never + # straight into the script (injection hardening). + env: + TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + run: | + if [ "$PRERELEASE" = "false" ]; then set -- --final; fi + python scripts/check_release_version.py --tag "$TAG" "$@" dist/* - name: no tenant identifiers or live platform hosts in the built dists (#821) # PyPI releases are immutable: this is the last chance to catch one. run: python scripts/scrub_fixtures.py --check dist/* @@ -61,6 +91,8 @@ jobs: exit 1 fi echo "Requires-Dist carries only >= floors." + - name: the built wheel installs and imports in a clean virtualenv (#767) + run: python scripts/smoke_test_wheel.py dist/*.whl - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist @@ -68,7 +100,9 @@ jobs: publish-pypi: name: Publish to PyPI (final release) - needs: build + # Every gate passes first: the suite at this commit, and the build job's + # tag/version check and clean-install smoke test (#767). + needs: [test, build] # Prod requires a *published, non-pre-release* GitHub Release. The explicit # event_name check means a manual dispatch can never reach prod — but this # workflow has no workflow_dispatch trigger at all, so that can't happen. @@ -86,3 +120,7 @@ jobs: - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist/ + # PEP 740 attestations, signed with this job's OIDC identity and + # uploaded with each file (#767). The action's default today; set + # explicitly so a default change can't drop them silently. + attestations: true diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index a12a5a86..4233587c 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -19,6 +19,25 @@ defaults: working-directory: python jobs: + test: + name: Test suite at the release commit + # #767: a dispatch does not wait for develop's CI, so the suite runs again + # here, on the exact commit being uploaded, before any upload. Same install + # and command as ci.yml's `test` job (3.11 leg). + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - run: >- + python -m pip install --upgrade pip && + pip install -e ".[llm,cli,local,otel]" --group dev -c constraints/test-py3.11.txt + - run: pytest -q + build: name: Build sdist + wheel runs-on: ubuntu-latest @@ -39,6 +58,10 @@ jobs: python -m build --no-isolation - name: twine check (PyPI long description renders cleanly) run: twine check dist/* + - name: every dist carries the declared version (#767) + # No tag here (dev snapshots are not GitHub Releases), but a stale or + # mis-spelled version must still fail before the unreusable upload. + run: python scripts/check_release_version.py dist/* - name: no tenant identifiers or live platform hosts in the built dists (#821) run: python scripts/scrub_fixtures.py --check dist/* - name: Floors, never ceilings (§8.4) — no upper pins in the built metadata @@ -59,6 +82,8 @@ jobs: exit 1 fi echo "Requires-Dist carries only >= floors." + - name: the built wheel installs and imports in a clean virtualenv (#767) + run: python scripts/smoke_test_wheel.py dist/*.whl - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist @@ -66,7 +91,9 @@ jobs: publish-testpypi: name: Publish to TestPyPI (dev dry-run) - needs: build + # Every gate passes first: the suite at this commit, and the build job's + # version check and clean-install smoke test (#767). + needs: [test, build] # develop-scoped: a dispatch from any other ref is skipped — a dev snapshot # only ever ships from develop, matching the branch model. if: ${{ github.ref == 'refs/heads/develop' }} @@ -84,3 +111,5 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ packages-dir: dist/ + # PEP 740 attestations, as on production (#767). + attestations: true diff --git a/docs/ci.md b/docs/ci.md index f5309a3d..f966c1fe 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -426,16 +426,20 @@ job on PRs is `docs-llms-drift`, so the site is first built after promotion to `main`. Both workflows still use Node 20, which reached end of life on 2026-04-30. -### 9. Release gates are code, not prose. **Target** (#767) - -Before any upload: the version is read from one source file, the tag equals -`v`, the unit suite passes, and the built wheel installs and imports -in a clean environment. The `pypi` environment requires reviewers, and the -upload generates attestations. The acceptance run against the published -pre-release is listed in `releasing.md`, and its status is attached to the -promotion PR. Today the version lives in both `python/pyproject.toml` and -`python/src/donkey_kit/__init__.py`, kept in sync by `scripts/bump-version.sh`, -and the publish build runs `twine check` and the upper-pin grep only. +### 9. Release gates are code, not prose. **Partly in place** (#767) + +The version is declared once, as `__version__` in +`python/src/donkey_kit/__init__.py`; `pyproject.toml` reads it from there. +Before either publish workflow uploads anything, its `test` job runs the suite +at that commit, and its `build` job checks that every built dist carries the +declared version (and, on `publish-pypi.yml`, that the tag equals +`v` and a final Release carries a final `X.Y.Z`), then installs the +built wheel into a clean virtualenv and imports it. The upload generates PEP +740 attestations. The acceptance run against the TestPyPI build is a listed +gate in [`releasing.md`](releasing.md#release-gates). Still by hand: required +reviewers on the `pypi` environment are a repository setting, so no workflow +can enforce them, and the acceptance result is recorded on the promotion PR +by the maintainer rather than reported by CI. ### 10. Workflow names follow one style. **In place** (#1009) diff --git a/docs/releasing.md b/docs/releasing.md index b1d86d04..69641c03 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -112,16 +112,22 @@ A release is an **annotated, `v`-prefixed git tag on `main`'s tip** (e.g. final version is flagged **pre-release** on its GitHub Release; only a final `X.Y.Z` drops that flag. -### The version string lives in two files - -The version is declared in **both** of these, which MUST always agree: - -- `python/pyproject.toml` → `version` -- `python/src/donkey_kit/__init__.py` → `__version__` - -Bump **both** on `develop`, in the PR that finishes a version's work, **before** -the promotion PR — so the code on `main` already reads the version its tag will -carry. A tag whose version disagrees with `__version__` at that commit is a bug. +### The version string lives in one file + +The version is declared **once**, as `__version__` in +`python/src/donkey_kit/__init__.py`. `python/pyproject.toml` declares the +version `dynamic` and hatch reads it from that line (`[tool.hatch.version]`), so +the built wheel, the sdist and `donkey_kit.__version__` cannot disagree. +`tests/unit/test_release_version.py` fails if `pyproject.toml` declares a +version of its own again. + +Bump it on `develop`, in the PR that finishes a version's work, **before** the +promotion PR — so the code on `main` already reads the version its tag will +carry. `scripts/bump-version.sh` does the bump: it asks for the issue that +tracks it (`--issue N`), names the branch `chore/-bump-version-` +per the branch convention, edits `__version__`, and opens the PR that closes +that issue. A tag whose version disagrees with `__version__` at that commit +fails the publish before any upload (see [Release gates](#release-gates)). ## How a release reaches PyPI @@ -137,26 +143,27 @@ overlap: flowchart TD start{What are you shipping?} - start -->|dev snapshot| bump["Bump .devN in pyproject.toml
and __init__.py, push to develop"] + start -->|dev snapshot| bump["Bump .devN in __version__
and land it on develop"] bump --> dispatch["Actions → Publish to TestPyPI
→ Run workflow"] dispatch --> ref{Dispatched from develop?} ref -->|no| skipped["publish job skipped"] - ref -->|yes| tbuild["publish-testpypi.yml
build job"] + ref -->|yes| tbuild["publish-testpypi.yml
test + build gates"] tbuild --> testpypi[("TestPyPI
env: testpypi")] + testpypi --> accept["Acceptance run against
the TestPyPI version"] start -->|final X.Y.Z| finish["Bump X.Y.Z on develop
milestone at 0 open issues"] finish --> promote["Promotion PR develop → main
no-fast-forward merge"] promote --> tag["Annotated vX.Y.Z tag on main's tip
+ GitHub Release"] tag --> pre{Release flagged
pre-release?} pre -->|yes| nowhere["Publishes nowhere
prerelease == false guard"] - pre -->|no| pbuild["publish-pypi.yml
build job"] + pre -->|no| pbuild["publish-pypi.yml
test + build gates"] pbuild --> approve["pypi environment
required-reviewer approval"] approve --> pypi[("PyPI
env: pypi")] classDef gate fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef publish fill:#fef3c7,stroke:#d97706,color:#78350f classDef dead fill:#f3f4f6,stroke:#9ca3af,color:#374151,stroke-dasharray: 4 3 - class tbuild,pbuild,approve gate + class tbuild,pbuild,approve,accept gate class testpypi,pypi publish class skipped,nowhere dead ``` @@ -164,11 +171,11 @@ flowchart TD - **Dev snapshots → TestPyPI, via `publish-testpypi.yml`'s manual `workflow_dispatch`.** Dev builds are deliberately **not** GitHub Releases — the Releases page is reserved for real releases. To dry-run: bump the - `.devN` counter (see [Versioning & naming](#versioning--naming)) in **both** - `python/pyproject.toml` and `python/src/donkey_kit/__init__.py` (they must - agree), push to `develop`, then **Actions → Publish to TestPyPI → Run - workflow** on `develop`. The publish job is guarded to - `refs/heads/develop` — a dispatch from any other ref is skipped. It + `.devN` counter (see [Versioning & naming](#versioning--naming)) in + `__version__` (`scripts/bump-version.sh`), land it on `develop`, then + **Actions → Publish to TestPyPI → Run workflow** on `develop`. The publish + job is guarded to `refs/heads/develop` — a dispatch from any other ref is + skipped. It publishes to `https://test.pypi.org/legacy/` via OIDC — the same trusted-publishing path prod uses. Each dry-run needs a fresh `.devN`: a filename, once uploaded to TestPyPI, can never be reused, even after @@ -199,26 +206,60 @@ sequenceDiagram actor R as pypi reviewer participant P as PyPI - M->>D: PR bumping version in pyproject.toml + __init__.py - M->>Main: Promotion PR, no-fast-forward merge + M->>D: PR bumping __version__ (scripts/bump-version.sh) + M->>Main: Promotion PR, no-fast-forward merge (records the acceptance run) M->>Main: Annotated tag vX.Y.Z on main's tip M->>GH: Publish non-pre-release Release (notes incl. Breaking changes) GH-->>W: release: published - W->>W: build: sdist + wheel, twine check,
scrub check, floors-never-ceilings + W->>W: test: the suite at the tagged commit + W->>W: build: sdist + wheel, twine check, tag = v,
scrub check, floors-never-ceilings, clean-install smoke test W->>R: publish-pypi job waits on pypi environment R-->>W: Approve - W->>P: Upload via OIDC trusted publishing + W->>P: Upload via OIDC trusted publishing, with attestations ``` -Each workflow runs its own `build` job first — sdist + wheel, `twine check`, -and the assertion that the built metadata carries only `>=` floors -(floors-never-ceilings) — then uploads via `pypa/gh-action-pypi-publish`. +Each workflow runs its own `test` and `build` jobs first, and uploads via +`pypa/gh-action-pypi-publish` only when both pass. The gates are listed in +[Release gates](#release-gates). Each is **structurally incapable** of reaching the other's destination: `publish-pypi.yml` has no `workflow_dispatch` trigger, so a manual run can never fire it; `publish-testpypi.yml` has no `release:` trigger, so a published Release can never fire it. Neither workflow creates tags or releases — they only react to them. +## Release gates + +A PyPI upload cannot be undone (a release can only be yanked), so every check +that can run before the upload does. The automated ones run in both publish +workflows; the last two are human. + +| Gate | Where | Fails when | +| --- | --- | --- | +| Test suite at the release commit | `test` job | `pytest -q` fails on the exact commit being uploaded (same install as `ci.yml`'s `test` job, 3.11). | +| One version, matching tag | `build` job, `scripts/check_release_version.py` | A built dist's metadata carries a version other than `__version__`, or the version isn't a normalised ladder version. On `publish-pypi.yml`, also when the tag isn't exactly `v`, or a non-pre-release Release carries a pre-release version. | +| `twine check`, no tenant identifiers, floors never ceilings | `build` job | The long description doesn't render, a dist carries a tenant identifier or live platform host, or the built metadata has an upper pin. | +| Clean-install smoke test | `build` job, `scripts/smoke_test_wheel.py` | The built wheel doesn't install into a fresh virtualenv, or `import donkey_kit` there fails, imports from anywhere but that virtualenv, or reports a `__version__` other than the installed version. | +| Attestations | publish job | Not a check: the upload carries [PEP 740](https://peps.python.org/pep-0740/) attestations signed with the workflow's OIDC identity (`attestations: true`). | +| Required reviewer | `pypi` environment | A maintainer doesn't approve the production deployment. This is a repository setting (see [One-time human setup](#one-time-human-setup-register-the-trusted-publisher)); the workflow can't enforce it. | +| Acceptance run | By hand, before the promotion PR | See below. | + +### The acceptance run + +Every gate above tests the source tree or a freshly built wheel. The +maintainers' acceptance suite, kept in a separate private repository, is the +only one that installs the **published** artifact from an index and exercises +it against a live gateway. It catches the release where all of CI is +green and consumers still can't use the package. + +Run it against the `.devN` version you just published to TestPyPI, before you +open the promotion PR. How to run it, and how to tell a run that tested +something from one that skipped everything, is documented in that repository. + +Record the result in the promotion PR body: the version tested, the index, and +whether it passed. A promotion with no recorded acceptance run is a promotion +nobody checked. If the run fails, don't promote until you know which side is +wrong, and assume the package is at fault until you have shown otherwise. + ## Hotfix releases A hotfix moves `main` without waiting for the next `develop → main` @@ -247,9 +288,9 @@ its PR. A hotfix that touches only `website/**` and/or `.github/workflows/docs.yml` changes no published artifact. It gets **no version bump, no tag, no GitHub -Release and no PyPI publish**: leave `pyproject.toml` and `__version__` -alone. It is done when the `docs.yml` run for the merge commit on `main` is -green and the live site serves the fix. A manual redeploy must run with +Release and no PyPI publish**: leave `__version__` alone. It is done when +the `docs.yml` run for the merge commit on `main` is green and the live site +serves the fix. A manual redeploy must run with `--ref main`, because the `github-pages` environment rejects any other ref. ### Package hotfix: next patch, milestones shift @@ -263,14 +304,14 @@ version is the milestone's, so the milestones move to make room: `X.Y` line move up one patch. Only the version in parentheses changes in each title, and their issues stay where they are. Update the [milestone table](#versioning--naming) to match in the same change. -- The hotfix PR bumps **both** version files to `X.Y.(Z+1)`, so `main` - already reads the version its tag will carry. +- The hotfix PR bumps `__version__` to `X.Y.(Z+1)`, so `main` already reads + the version its tag will carry. - After the rebase-merge, tag `main`'s tip `vX.Y.(Z+1)` and cut a GitHub Release as usual. A hotfix on a final release is itself final, so it is a non-pre-release Release and `publish-pypi.yml` publishes it to PyPI. A hotfix on a pre-release (`main` at `X.Y.Za1`, say) keeps climbing the ladder (`X.Y.Za2`) instead, takes no milestone version and shifts nothing. -- The cherry-pick PR to `develop` conflicts on the version files. Resolve it +- The cherry-pick PR to `develop` conflicts on the `__version__` line. Resolve it by setting `develop` to `X.Y.(Z+2).dev0`, the next unshipped version, so `develop` always sorts above `main`. @@ -292,7 +333,7 @@ flowchart TD docsMerge --> docsYml["docs.yml rebuilds the live site"] docsMerge --> noRelease["No bump, no tag,
no Release, no PyPI"] - kind -->|no: package hotfix| bump["Bump both version files
to the next patch"] + kind -->|no: package hotfix| bump["Bump __version__
to the next patch"] bump --> milestones["Shift later open patch
milestones up one patch"] milestones --> pkgPr["PR → main"] pkgPr --> pkgCi["ci.yml + secret-scan.yml"] @@ -381,9 +422,10 @@ fallback for an index where the project does not exist yet. Enter, on the These must match the workflow exactly or PyPI rejects the OIDC token. Then, in the GitHub repo (Settings → Environments), create the `pypi` and -`testpypi` **Environments**. Adding **required reviewers** to `pypi` is -recommended: the publish job then pauses for a human approval before the -irreversible production upload — the go-ahead gate #339 requires. +`testpypi` **Environments**. Add **required reviewers** to `pypi`: the publish +job then pauses for a human approval before the irreversible production +upload — the go-ahead gate #339 requires, and one of the +[release gates](#release-gates). > A public PyPI publish is effectively irreversible (names can be squatted; > releases can only be *yanked*, never deleted). Do not change the diff --git a/python/pyproject.toml b/python/pyproject.toml index e6a4854e..3fbc1770 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,11 @@ build-backend = "hatchling.build" [project] name = "donkey-kit" -version = "0.1.2" +# The version is declared once, as `__version__` in src/donkey_kit/__init__.py, +# and hatch reads it from there ([tool.hatch.version] below, #767). Bump only +# that file (scripts/bump-version.sh does it); test_release_version.py checks +# nothing here re-declares it. +dynamic = ["version"] description = "An SDK for consuming Agent Fabric capabilities (governed model and tool access) from your own agent framework." readme = "README.md" requires-python = ">=3.10" @@ -204,6 +208,12 @@ donkey = "donkey_kit.cli:main" # imported lazily. The plugin is inert unless `--donkey-conformance` is passed. donkey_kit_conformance = "donkey_kit.conformance.plugin" +# The one version source (#767): hatch's default pattern reads the +# `__version__ = "..."` line, and the wheel, the sdist and `__version__` can no +# longer disagree. +[tool.hatch.version] +path = "src/donkey_kit/__init__.py" + [tool.hatch.build.targets.wheel] packages = ["src/donkey_kit"] diff --git a/python/scripts/check_release_version.py b/python/scripts/check_release_version.py new file mode 100644 index 00000000..b3bf6bc4 --- /dev/null +++ b/python/scripts/check_release_version.py @@ -0,0 +1,125 @@ +"""Fail a publish whose version, tag and built dists disagree (#767). + +The package version is declared once, as ``__version__`` in +``src/donkey_kit/__init__.py``; ``pyproject.toml`` declares it dynamic and hatch +reads that line. A PyPI upload cannot be undone, so before either publish +workflow uploads anything this script checks that: + +* the declared version is a normalised PEP 440 version on the release ladder + (``X.Y.Z``, ``X.Y.Z.devN``, ``X.Y.ZaN``, ``X.Y.ZbN`` or ``X.Y.ZrcN``, + docs/releasing.md "Versioning & naming"); +* with ``--tag``, the git tag is exactly ``v``; +* with ``--final``, the version is a final ``X.Y.Z`` (only a final release + reaches production PyPI); +* every built dist given on the command line carries that same version, read + from the dist's own metadata (``METADATA`` in a wheel, ``PKG-INFO`` in an + sdist), not from its filename alone. + + python scripts/check_release_version.py [--tag TAG] [--final] [DIST ...] + +Exits 1 and lists every problem when any is found. Standard library only, so +it runs in the publish workflows' bare build environment. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tarfile +import zipfile +from collections.abc import Sequence +from email.parser import HeaderParser +from pathlib import Path + +INIT_PY = Path(__file__).resolve().parents[1] / "src" / "donkey_kit" / "__init__.py" + +# The same line hatch's default version pattern reads ([tool.hatch.version]). +_DECLARED = re.compile(r"^__version__\s*=\s*[\"']([^\"']+)[\"']\s*$", re.MULTILINE) +# Normalised PEP 440 spellings only, so the tag, the filename and the PyPI +# version are byte-identical (no `0.1.0-alpha.1`, no `0.1.0.dev`). +_LADDER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.dev\d+|a\d+|b\d+|rc\d+)?$") +_FINAL = re.compile(r"^\d+\.\d+\.\d+$") + + +def declared_version(init_py: Path = INIT_PY) -> str: + """The one ``__version__ = "..."`` line in ``init_py``.""" + found = _DECLARED.findall(init_py.read_text(encoding="utf-8")) + if len(found) != 1: + raise ValueError(f"{init_py}: expected one __version__ line, found {len(found)}") + return str(found[0]) + + +def _metadata_version(raw: bytes, source: str) -> str: + version = HeaderParser().parsestr(raw.decode("utf-8")).get("Version") + if not version: + raise ValueError(f"{source}: no Version field") + return str(version) + + +def dist_version(dist: Path) -> str: + """The ``Version`` a built wheel or sdist declares in its own metadata.""" + if dist.suffix == ".whl": + with zipfile.ZipFile(dist) as whl: + names = [n for n in whl.namelist() if re.fullmatch(r"[^/]+\.dist-info/METADATA", n)] + if len(names) != 1: + raise ValueError(f"{dist.name}: expected one .dist-info/METADATA, found {names}") + return _metadata_version(whl.read(names[0]), f"{dist.name}:{names[0]}") + if dist.name.endswith(".tar.gz"): + with tarfile.open(dist, "r:gz") as sdist: + members = [m for m in sdist.getmembers() if re.fullmatch(r"[^/]+/PKG-INFO", m.name)] + if len(members) != 1: + raise ValueError(f"{dist.name}: expected one top-level PKG-INFO") + handle = sdist.extractfile(members[0]) + if handle is None: + raise ValueError(f"{dist.name}: PKG-INFO is not a regular file") + return _metadata_version(handle.read(), f"{dist.name}:{members[0].name}") + raise ValueError(f"{dist.name}: not a wheel (.whl) or an sdist (.tar.gz)") + + +def problems( + version: str, *, tag: str | None = None, final: bool = False, dists: Sequence[Path] = () +) -> list[str]: + """Every way ``version``, ``tag`` and ``dists`` fail to agree; empty when they do.""" + found: list[str] = [] + if not _LADDER.match(version): + found.append( + f"version {version!r} is not a normalised PEP 440 release-ladder version " + "(X.Y.Z, X.Y.Z.devN, X.Y.ZaN, X.Y.ZbN or X.Y.ZrcN)" + ) + if tag is not None and tag != f"v{version}": + found.append(f"tag {tag!r} does not match the package version: expected 'v{version}'") + if final and not _FINAL.match(version): + found.append(f"version {version!r} is a pre-release; production PyPI takes only X.Y.Z") + for dist in dists: + try: + built = dist_version(dist) + except (OSError, ValueError, zipfile.BadZipFile, tarfile.TarError) as exc: + found.append(f"{dist.name}: cannot read its version ({exc})") + continue + if built != version: + found.append(f"{dist.name} was built as {built!r}, not {version!r}") + return found + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("dists", nargs="*", type=Path, help="built wheel/sdist files to check") + parser.add_argument("--tag", help="the release's git tag; must equal v") + parser.add_argument("--final", action="store_true", help="require a final X.Y.Z version") + args = parser.parse_args(argv) + + version = declared_version() + found = problems(version, tag=args.tag, final=args.final, dists=args.dists) + if found: + for problem in found: + print(f"error: {problem}", file=sys.stderr) + return 1 + checked = ", ".join(d.name for d in args.dists) or "no dists" + tag = f"tag {args.tag}, " if args.tag else "" + print(f"release version {version}: {tag}{checked} agree") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/scripts/smoke_test_wheel.py b/python/scripts/smoke_test_wheel.py new file mode 100644 index 00000000..ff4ab901 --- /dev/null +++ b/python/scripts/smoke_test_wheel.py @@ -0,0 +1,121 @@ +"""Install a built wheel into a clean virtualenv and import it (#767). + +``twine check`` proves the long description renders; it does not prove the +package installs or imports. A PyPI upload cannot be undone, so both publish +workflows run this on the exact wheel they are about to upload. It: + +* creates a fresh virtualenv (no site packages, nothing from this checkout); +* ``pip install``s the wheel into it, resolving the base dependencies the way + a consumer's ``pip install donkey-kit`` would; +* from an empty working directory, in isolated mode (so the checkout can never + shadow the installed package), imports ``donkey_kit`` and the ``Donkey`` + entry point, and checks the import came from the virtualenv's + site-packages and that ``__version__`` equals the installed distribution's + version and the expected one. + + python scripts/smoke_test_wheel.py dist/*.whl [--expect-version VERSION] + +The workflows pass a glob, so it must expand to exactly one wheel: a second +one (a stale file, or a platform wheel nobody planned for) is an error rather +than something to install alongside and leave unchecked. +``--expect-version`` defaults to the version in the wheel's filename. Each +``--pip-arg=ARG`` is passed through to ``pip install`` (the unit test passes +``--pip-arg=--no-index`` to stay offline). Exits non-zero on any failure. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +import venv +from collections.abc import Sequence +from pathlib import Path + +# Runs inside the clean virtualenv. argv[1] is the expected version. +_PROBE = """ +import importlib.metadata +import sys +import sysconfig +from pathlib import Path + +import donkey_kit +from donkey_kit import Donkey + +expected = sys.argv[1] +installed = importlib.metadata.version("donkey-kit") +where = Path(donkey_kit.__file__).resolve() +site = Path(sysconfig.get_paths()["purelib"]).resolve() +if site not in where.parents: + raise SystemExit(f"donkey_kit imported from {where}, not from {site}") +if not donkey_kit.__version__ == installed == expected: + raise SystemExit( + f"__version__ {donkey_kit.__version__!r}, installed {installed!r}, expected {expected!r}" + ) +if not callable(Donkey): + raise SystemExit("donkey_kit.Donkey is not callable") +print(f"donkey-kit {installed} installs and imports from {where.parent}") +""" + + +def wheel_version(wheel: Path) -> str: + """The version field of a wheel filename (PEP 427: name-version-...).""" + parts = wheel.name.split("-") + if wheel.suffix != ".whl" or len(parts) < 5: + raise ValueError(f"{wheel.name} is not a wheel filename") + return parts[1] + + +def _bin(env_dir: Path, name: str) -> Path: + return env_dir / ("Scripts" if sys.platform == "win32" else "bin") / name + + +def smoke_test(wheel: Path, expected: str, pip_args: Sequence[str] = ()) -> None: + """Install ``wheel`` into a fresh virtualenv and run the import probe there.""" + wheel = wheel.resolve() + with tempfile.TemporaryDirectory(prefix="ddk-wheel-smoke-") as scratch: + env_dir = Path(scratch) / "venv" + empty_cwd = Path(scratch) / "cwd" + empty_cwd.mkdir() + venv.EnvBuilder(with_pip=True, clear=True).create(env_dir) + python = str(_bin(env_dir, "python")) + subprocess.run( + [python, "-m", "pip", "install", "--disable-pip-version-check", *pip_args, str(wheel)], + check=True, + cwd=empty_cwd, + ) + subprocess.run([python, "-I", "-c", _PROBE, expected], check=True, cwd=empty_cwd) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("wheels", type=Path, nargs="+", help="the built .whl (exactly one)") + parser.add_argument("--expect-version", help="defaults to the wheel filename's version") + parser.add_argument( + "--pip-arg", + action="append", + default=[], + dest="pip_args", + metavar="ARG", + help="extra `pip install` argument; repeatable (write --pip-arg=--flag)", + ) + args = parser.parse_args(argv) + + if len(args.wheels) != 1: + names = ", ".join(w.name for w in args.wheels) + count = len(args.wheels) + print(f"error: expected exactly one wheel, got {count}: {names}", file=sys.stderr) + return 1 + (wheel,) = args.wheels + expected = args.expect_version or wheel_version(wheel) + try: + smoke_test(wheel, expected, args.pip_args) + except subprocess.CalledProcessError as exc: + print(f"error: wheel smoke test failed ({exc})", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/src/donkey_kit/__init__.py b/python/src/donkey_kit/__init__.py index 7cc950b2..cfe708c3 100644 --- a/python/src/donkey_kit/__init__.py +++ b/python/src/donkey_kit/__init__.py @@ -160,6 +160,9 @@ def __dir__() -> list[str]: # records stay silent until the application configures logging (#717). _logging.getLogger(__name__).addHandler(_logging.NullHandler()) +# The package's one version source (#767): pyproject.toml declares the version +# dynamic and hatch reads this line, so bump it here and nowhere else. The +# release workflows fail when a tag disagrees with it (docs/releasing.md). __version__ = "0.1.2" __all__ = [ diff --git a/python/tests/unit/test_release_version.py b/python/tests/unit/test_release_version.py new file mode 100644 index 00000000..9714f909 --- /dev/null +++ b/python/tests/unit/test_release_version.py @@ -0,0 +1,184 @@ +"""One version source, and a publish that fails when the tag disagrees (#767). + +``pyproject.toml`` declares the version dynamic and hatch reads it from +``__version__``, so the two can no longer drift apart (an editable install was +seen reporting 0.1.1.dev0 while ``__version__`` said 0.1.1.dev2). +``scripts/check_release_version.py`` is the gate both publish workflows run +before any upload: tag equals ``v``, prod takes only a final version, +and every built dist carries the declared version in its own metadata. + +Framework-free: it reads files and builds tiny dists in ``tmp_path``. +""" + +from __future__ import annotations + +import importlib.util +import io +import sys +import tarfile +import zipfile +from pathlib import Path +from types import ModuleType + +import pytest + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - 3.10 backfill + import tomli as tomllib + +_PYTHON_ROOT = Path(__file__).resolve().parents[2] +_SCRIPT = _PYTHON_ROOT / "scripts" / "check_release_version.py" + + +def _load() -> ModuleType: + name = "ddk_check_release_version" + spec = importlib.util.spec_from_file_location(name, _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +check = _load() + + +def _pyproject() -> dict[str, object]: + data = tomllib.loads((_PYTHON_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert isinstance(data, dict) + return data + + +def _metadata(version: str) -> bytes: + return f"Metadata-Version: 2.4\nName: donkey-kit\nVersion: {version}\n\n".encode() + + +def _wheel(tmp_path: Path, version: str, *, filename_version: str | None = None) -> Path: + path = tmp_path / f"donkey_kit-{filename_version or version}-py3-none-any.whl" + with zipfile.ZipFile(path, "w") as whl: + whl.writestr(f"donkey_kit-{version}.dist-info/METADATA", _metadata(version)) + return path + + +def _sdist(tmp_path: Path, version: str) -> Path: + path = tmp_path / f"donkey_kit-{version}.tar.gz" + raw = _metadata(version) + with tarfile.open(path, "w:gz") as sdist: + info = tarfile.TarInfo(f"donkey_kit-{version}/PKG-INFO") + info.size = len(raw) + sdist.addfile(info, io.BytesIO(raw)) + return path + + +# --- one version source ----------------------------------------------------- + + +def test_pyproject_reads_the_version_from_init_py() -> None: + data = _pyproject() + project = data["project"] + assert isinstance(project, dict) + assert "version" not in project, "the version is declared in __init__.py only" + assert "version" in project["dynamic"] + tool = data["tool"] + assert isinstance(tool, dict) + assert tool["hatch"]["version"] == {"path": "src/donkey_kit/__init__.py"} + + +def test_init_py_declares_one_ladder_version() -> None: + version = check.declared_version() + assert check.problems(version) == [] + + +def test_declared_version_is_the_runtime_version() -> None: + import donkey_kit + + assert check.declared_version() == donkey_kit.__version__ + + +def test_bump_script_edits_only_the_one_version_source() -> None: + script = _PYTHON_ROOT.parent / "scripts" / "bump-version.sh" + if not script.is_file(): + pytest.skip("not a repo checkout (no scripts/bump-version.sh)") + text = script.read_text(encoding="utf-8") + assert 'INIT_PY="python/src/donkey_kit/__init__.py"' in text + assert "PYPROJECT" not in text, "the bump touches __init__.py only" + # The /- branch convention, not release/bump-. + assert 'BRANCH="chore/${ISSUE}-bump-version-${NEW//./-}"' in text + assert "release/bump" not in text + + +def test_declared_version_rejects_zero_or_two_lines(tmp_path: Path) -> None: + init = tmp_path / "__init__.py" + init.write_text('"""no version here."""\n', encoding="utf-8") + with pytest.raises(ValueError, match="found 0"): + check.declared_version(init) + init.write_text('__version__ = "1.0.0"\n__version__ = "1.0.1"\n', encoding="utf-8") + with pytest.raises(ValueError, match="found 2"): + check.declared_version(init) + init.write_text('__version__ = "1.2.3.dev4"\n', encoding="utf-8") + assert check.declared_version(init) == "1.2.3.dev4" + + +# --- the version / tag / dist gate ----------------------------------------- + + +@pytest.mark.parametrize("version", ["0.1.2", "0.1.3.dev0", "1.0.0a1", "1.0.0b2", "1.0.0rc10"]) +def test_ladder_versions_pass(version: str) -> None: + assert check.problems(version, tag=f"v{version}") == [] + + +@pytest.mark.parametrize("version", ["0.1.0-alpha.1", "0.1", "0.1.0.dev", "v0.1.0", "01.0.0"]) +def test_unnormalised_versions_fail(version: str) -> None: + assert check.problems(version) != [] + + +@pytest.mark.parametrize("tag", ["0.1.2", "v0.1.3", "v0.1.2rc1", "release-0.1.2", "V0.1.2"]) +def test_a_tag_that_is_not_v_version_fails(tag: str) -> None: + (problem,) = check.problems("0.1.2", tag=tag) + assert "expected 'v0.1.2'" in problem + + +@pytest.mark.parametrize("version", ["0.1.3.dev0", "0.1.3a1", "0.1.3rc1"]) +def test_final_rejects_a_pre_release(version: str) -> None: + (problem,) = check.problems(version, tag=f"v{version}", final=True) + assert "pre-release" in problem + + +def test_final_accepts_a_final_release() -> None: + assert check.problems("0.1.3", tag="v0.1.3", final=True) == [] + + +def test_dists_that_carry_the_version_pass(tmp_path: Path) -> None: + dists = [_wheel(tmp_path, "0.1.3"), _sdist(tmp_path, "0.1.3")] + assert check.problems("0.1.3", tag="v0.1.3", final=True, dists=dists) == [] + + +def test_a_dist_built_at_another_version_fails(tmp_path: Path) -> None: + stale = _wheel(tmp_path, "0.1.2") + (problem,) = check.problems("0.1.3", dists=[stale, _sdist(tmp_path, "0.1.3")]) + assert stale.name in problem and "'0.1.2'" in problem + + +def test_the_dist_metadata_wins_over_its_filename(tmp_path: Path) -> None: + renamed = _wheel(tmp_path, "0.1.2", filename_version="0.1.3") + assert check.problems("0.1.3", dists=[renamed]) != [] + + +def test_an_unreadable_dist_fails(tmp_path: Path) -> None: + junk = tmp_path / "donkey_kit-0.1.3-py3-none-any.whl" + junk.write_bytes(b"not a zip") + other = tmp_path / "donkey_kit-0.1.3.zip" + other.write_bytes(b"") + found = check.problems("0.1.3", dists=[junk, other]) + assert len(found) == 2 + assert all("cannot read its version" in p for p in found) + + +def test_main_exit_codes(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + version = check.declared_version() + wheel = _wheel(tmp_path, version) + assert check.main(["--tag", f"v{version}", str(wheel)]) == 0 + assert version in capsys.readouterr().out + assert check.main(["--tag", "v0.0.0", str(wheel)]) == 1 + assert "does not match" in capsys.readouterr().err diff --git a/python/tests/unit/test_release_workflows.py b/python/tests/unit/test_release_workflows.py new file mode 100644 index 00000000..278fb4e7 --- /dev/null +++ b/python/tests/unit/test_release_workflows.py @@ -0,0 +1,92 @@ +"""Both publish workflows gate the upload on code, not on prose (#767). + +A PyPI upload cannot be undone, so before ``publish-pypi.yml`` or +``publish-testpypi.yml`` uploads anything: the test suite runs at that commit, +the version check runs on the built dists (production also checks the tag), +the built wheel installs and imports in a clean virtualenv, and the upload +carries PEP 740 attestations. This pins that wiring, so removing a gate fails a +test instead of going unnoticed until a bad release. + +Framework-free: it reads the workflow files only. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml + +_WORKFLOWS = Path(__file__).resolve().parents[3] / ".github" / "workflows" + +# workflow file -> its upload job +_PUBLISHERS = {"publish-pypi.yml": "publish-pypi", "publish-testpypi.yml": "publish-testpypi"} + + +def _jobs(name: str) -> dict[str, dict[str, Any]]: + path = _WORKFLOWS / name + if not path.is_file(): + pytest.skip(f"not a repo checkout (no .github/workflows/{name})") + jobs = yaml.safe_load(path.read_text(encoding="utf-8"))["jobs"] + assert isinstance(jobs, dict) + return jobs + + +def _runs(job: dict[str, Any]) -> list[str]: + return [str(step["run"]) for step in job["steps"] if "run" in step] + + +def _needs(job: dict[str, Any]) -> set[str]: + needs = job["needs"] + return {needs} if isinstance(needs, str) else set(needs) + + +@pytest.fixture(params=sorted(_PUBLISHERS)) +def workflow(request: pytest.FixtureRequest) -> str: + name = request.param + assert isinstance(name, str) + return name + + +def test_the_upload_waits_for_every_other_job(workflow: str) -> None: + jobs = _jobs(workflow) + upload = _PUBLISHERS[workflow] + assert _needs(jobs[upload]) == set(jobs) - {upload} + assert {"test", "build"} <= set(jobs) + + +def test_the_suite_runs_at_the_release_commit(workflow: str) -> None: + runs = _runs(_jobs(workflow)["test"]) + assert "pytest -q" in runs + + +def test_the_build_checks_the_version_then_smoke_tests_the_wheel(workflow: str) -> None: + runs = _runs(_jobs(workflow)["build"]) + build = next(i for i, r in enumerate(runs) if "python -m build" in r) + check = next(i for i, r in enumerate(runs) if "scripts/check_release_version.py" in r) + smoke = next(i for i, r in enumerate(runs) if "scripts/smoke_test_wheel.py dist/*.whl" in r) + assert build < check and build < smoke + assert "dist/*" in runs[check] + + +def test_production_checks_the_tag_and_requires_a_final_version() -> None: + build = _jobs("publish-pypi.yml")["build"] + (step,) = [s for s in build["steps"] if "check_release_version.py" in s.get("run", "")] + assert '--tag "$TAG"' in step["run"] and "--final" in step["run"] + # Event values reach the script through env, never interpolated into run:. + assert step["env"]["TAG"] == "${{ github.event.release.tag_name }}" + assert "${{" not in step["run"] + + +def test_production_still_skips_pre_release_releases() -> None: + upload = _jobs("publish-pypi.yml")["publish-pypi"] + assert "github.event.release.prerelease == false" in upload["if"] + assert upload["environment"] == "pypi" + + +def test_the_upload_generates_attestations(workflow: str) -> None: + steps = _jobs(workflow)[_PUBLISHERS[workflow]]["steps"] + action = "pypa/gh-action-pypi-publish@" + (publish,) = [s for s in steps if str(s.get("uses", "")).startswith(action)] + assert publish["with"]["attestations"] is True diff --git a/python/tests/unit/test_smoke_test_wheel.py b/python/tests/unit/test_smoke_test_wheel.py new file mode 100644 index 00000000..3d3ae549 --- /dev/null +++ b/python/tests/unit/test_smoke_test_wheel.py @@ -0,0 +1,105 @@ +"""``scripts/smoke_test_wheel.py`` installs a wheel cleanly and imports it (#767). + +Both publish workflows run it on the exact wheel they are about to upload. The +real wheel needs httpx from an index, so these tests build a stand-in wheel +with no dependencies and install it with ``--no-index``: offline, and still a +real virtualenv, a real ``pip install`` and the real import probe. +""" + +from __future__ import annotations + +import base64 +import hashlib +import importlib.util +import sys +import zipfile +from pathlib import Path +from types import ModuleType + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "smoke_test_wheel.py" + + +def _load() -> ModuleType: + name = "ddk_smoke_test_wheel" + spec = importlib.util.spec_from_file_location(name, _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +smoke = _load() + + +def _record_line(name: str, data: bytes) -> str: + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() + return f"{name},sha256={digest},{len(data)}" + + +def _wheel(tmp_path: Path, *, version: str, declared: str) -> Path: + """A minimal, dependency-free ``donkey-kit`` wheel whose ``__version__`` is ``declared``.""" + dist_info = f"donkey_kit-{version}.dist-info" + files = { + "donkey_kit/__init__.py": ( + f'__version__ = "{declared}"\n\n\nclass Donkey:\n """Stand-in."""\n' + ).encode(), + f"{dist_info}/METADATA": ( + f"Metadata-Version: 2.4\nName: donkey-kit\nVersion: {version}\n\n" + ).encode(), + f"{dist_info}/WHEEL": ( + b"Wheel-Version: 1.0\nGenerator: ddk-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + ), + } + record = [_record_line(name, data) for name, data in files.items()] + record.append(f"{dist_info}/RECORD,,") + path = tmp_path / f"donkey_kit-{version}-py3-none-any.whl" + with zipfile.ZipFile(path, "w") as whl: + for name, data in files.items(): + whl.writestr(name, data) + whl.writestr(f"{dist_info}/RECORD", "\n".join(record) + "\n") + return path + + +def test_wheel_version_reads_the_filename() -> None: + assert smoke.wheel_version(Path("donkey_kit-0.1.3.dev0-py3-none-any.whl")) == "0.1.3.dev0" + with pytest.raises(ValueError, match="not a wheel"): + smoke.wheel_version(Path("donkey_kit-0.1.3.tar.gz")) + + +@pytest.mark.timeout(180) +def test_a_good_wheel_installs_and_imports( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + wheel = _wheel(tmp_path, version="9.8.7", declared="9.8.7") + assert smoke.main([str(wheel), "--pip-arg=--no-index"]) == 0 + assert "donkey-kit 9.8.7 installs and imports from" in capfd.readouterr().out + + +@pytest.mark.timeout(180) +def test_a_wheel_whose_version_disagrees_fails( + tmp_path: Path, capfd: pytest.CaptureFixture[str] +) -> None: + # The wheel metadata says 9.8.7, but the code inside reports 9.8.6: the + # exact drift a two-file version allowed. + wheel = _wheel(tmp_path, version="9.8.7", declared="9.8.6") + assert smoke.main([str(wheel), "--pip-arg=--no-index"]) == 1 + assert "__version__ '9.8.6', installed '9.8.7'" in capfd.readouterr().err + + +@pytest.mark.timeout(180) +def test_an_unexpected_version_fails(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None: + wheel = _wheel(tmp_path, version="9.8.7", declared="9.8.7") + assert smoke.main([str(wheel), "--expect-version", "9.8.8", "--pip-arg=--no-index"]) == 1 + assert "expected '9.8.8'" in capfd.readouterr().err + + +def test_more_than_one_wheel_is_refused(tmp_path: Path, capfd: pytest.CaptureFixture[str]) -> None: + # ``dist/*.whl`` matching a stale second wheel must fail loudly, not + # install both and check only the first. + first = _wheel(tmp_path, version="9.8.7", declared="9.8.7") + stale = _wheel(tmp_path, version="9.8.6", declared="9.8.6") + assert smoke.main([str(first), str(stale), "--pip-arg=--no-index"]) == 1 + assert "expected exactly one wheel, got 2" in capfd.readouterr().err diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 66d68523..a955b349 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -2,12 +2,17 @@ # # Interactive version bump → PR into develop → auto-merge on CI green. # -# Bumps the package version in lockstep across the two files that must always -# agree (see [[ddk-release]] "Single source of version truth"): -# python/pyproject.toml version = "…" +# Bumps the package version in its one source file (#767): # python/src/donkey_kit/__init__.py __version__ = "…" +# (python/pyproject.toml declares the version dynamic and hatch reads it from +# there, so there is nothing to keep in lockstep; see docs/releasing.md.) # -# then opens a squash PR into `develop` (branch → develop is always a squash +# The bump is tracked by an issue like any other change: the branch is named +# chore/-bump-version- per the /- +# convention, and the PR body closes the issue. Pass it with --issue N, or the +# script asks for it. +# +# It then opens a squash PR into `develop` (branch → develop is always a squash # merge, per [[ddk-merge-strategy]]) and enables GitHub auto-merge so it lands # the moment the blocking CI gates pass (base-only, typecheck-and-lint incl. # mypy --strict + lint-imports, and the test matrix). It then waits for the @@ -28,30 +33,36 @@ # behind your back. Nothing lands until CI is green. # # Usage: -# bash scripts/bump-version.sh # interactive -# bash scripts/bump-version.sh --dry-run # show every action, mutate nothing -# bash scripts/bump-version.sh --yes # skip the final confirm prompt +# bash scripts/bump-version.sh # interactive +# bash scripts/bump-version.sh --issue 1234 # the issue tracking this bump +# bash scripts/bump-version.sh --dry-run # show every action, mutate nothing +# bash scripts/bump-version.sh --yes # skip the final confirm prompt # set -uo pipefail REPO="Donkey-Development-Kit/donkey-development-kit" BASE_BRANCH="develop" -PYPROJECT="python/pyproject.toml" INIT_PY="python/src/donkey_kit/__init__.py" MERGE_POLL_SECS="${MERGE_POLL_SECS:-15}" MERGE_TIMEOUT_SECS="${MERGE_TIMEOUT_SECS:-1800}" DRY_RUN=0 ASSUME_YES=0 -for arg in "$@"; do - case "$arg" in +ISSUE="" +while [ "$#" -gt 0 ]; do + case "$1" in --dry-run) DRY_RUN=1 ;; --yes|-y) ASSUME_YES=1 ;; + --issue) + [ "$#" -ge 2 ] || { printf 'error: --issue needs an issue number\n' >&2; exit 2; } + ISSUE="$2"; shift ;; + --issue=*) ISSUE="${1#--issue=}" ;; -h|--help) grep '^#' "$0" | sed -E 's/^# ?//' | sed -n '2,40p' exit 0 ;; - *) printf 'error: unknown argument: %s\n' "$arg" >&2; exit 2 ;; + *) printf 'error: unknown argument: %s\n' "$1" >&2; exit 2 ;; esac + shift done BOLD=$'\033[1m'; RESET=$'\033[0m' @@ -70,7 +81,7 @@ run() { # --- Locate the repo root and cd there so paths resolve regardless of cwd ----- ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || die "not inside a git repository" cd "$ROOT" || die "cannot cd to repo root: $ROOT" -[ -f "$PYPROJECT" ] || die "$PYPROJECT not found — run this from the donkey-development-kit repo" +[ -f "$INIT_PY" ] || die "$INIT_PY not found — run this from the donkey-development-kit repo" # --- Preconditions ------------------------------------------------------------ command -v gh >/dev/null 2>&1 || die "the GitHub CLI (gh) is required; install it and 'gh auth login'" @@ -85,15 +96,9 @@ if [ -n "$(git status --porcelain)" ]; then die "working tree is not clean — commit or stash first (this script edits tracked version files)" fi -# --- Read + verify the current version (the two files MUST agree) ------------- -current_pyproject=$(sed -nE 's/^version = "([^"]+)"/\1/p' "$PYPROJECT" | head -1) -current_init=$(sed -nE 's/^__version__ = "([^"]+)"/\1/p' "$INIT_PY" | head -1) -[ -n "$current_pyproject" ] || die "could not read version from $PYPROJECT" -[ -n "$current_init" ] || die "could not read __version__ from $INIT_PY" -if [ "$current_pyproject" != "$current_init" ]; then - die "version mismatch: $PYPROJECT says '$current_pyproject' but $INIT_PY says '$current_init' — fix by hand first" -fi -CURRENT="$current_pyproject" +# --- Read the current version from its one source ---------------------------- +CURRENT=$(sed -nE 's/^__version__ = "([^"]+)"/\1/p' "$INIT_PY" | head -1) +[ -n "$CURRENT" ] || die "could not read __version__ from $INIT_PY" info "current version: ${BOLD}${CURRENT}${RESET}" # --- Parse the PEP 440 version ------------------------------------------------ @@ -186,12 +191,26 @@ PY esac fi +# --- The issue this bump closes (branch naming: /-) ------- +if [ -z "$ISSUE" ]; then + read -r -p "Issue number tracking this bump: " ISSUE +fi +ISSUE="${ISSUE#\#}" +[[ "$ISSUE" =~ ^[1-9][0-9]*$ ]] || die "not an issue number: '$ISSUE'" +if [ "$DRY_RUN" -eq 0 ]; then + issue_state=$(gh issue view "$ISSUE" --repo "$REPO" --json state --jq .state 2>/dev/null) \ + || die "issue #$ISSUE not found in $REPO" + [ "$issue_state" = "OPEN" ] || die "issue #$ISSUE is $issue_state; a bump closes an open issue" +fi + # --- Confirm ------------------------------------------------------------------ -BRANCH="release/bump-${NEW}" +# The slug is kebab-case with no punctuation: 0.1.3.dev0 -> 0-1-3-dev0. +BRANCH="chore/${ISSUE}-bump-version-${NEW//./-}" printf '\n' printf ' version: \033[1m%s\033[0m → \033[1m%s\033[0m\n' "$CURRENT" "$NEW" +printf ' issue: #%s\n' "$ISSUE" printf ' branch: %s (off %s)\n' "$BRANCH" "$BASE_BRANCH" -printf ' files: %s, %s\n' "$PYPROJECT" "$INIT_PY" +printf ' file: %s\n' "$INIT_PY" printf ' merge: squash into %s, auto-merge when CI is green\n' "$BASE_BRANCH" [ "$DRY_RUN" -eq 1 ] && printf ' \033[33mmode: DRY RUN — nothing will be changed\033[0m\n' printf '\n' @@ -215,30 +234,28 @@ if git show-ref --verify --quiet "refs/heads/${BRANCH}"; then fi run git switch -c "$BRANCH" "origin/${BASE_BRANCH}" -# --- Edit the two files in lockstep (anchored, exact-match on current) -------- +# --- Edit the one version source (anchored, exact-match on current) ---------- if [ "$DRY_RUN" -eq 1 ]; then - printf '\033[33m[dry-run]\033[0m would set version %s → %s in %s and %s\n' \ - "$CURRENT" "$NEW" "$PYPROJECT" "$INIT_PY" + printf '\033[33m[dry-run]\033[0m would set __version__ %s → %s in %s\n' \ + "$CURRENT" "$NEW" "$INIT_PY" else - perl -i -pe "s/^version = \"\Q$CURRENT\E\"/version = \"$NEW\"/" "$PYPROJECT" || die "failed to edit $PYPROJECT" - perl -i -pe "s/^__version__ = \"\Q$CURRENT\E\"/__version__ = \"$NEW\"/" "$INIT_PY" || die "failed to edit $INIT_PY" - # Verify both actually changed and now agree. - got_p=$(sed -nE 's/^version = "([^"]+)"/\1/p' "$PYPROJECT" | head -1) + perl -i -pe "s/^__version__ = \"\Q$CURRENT\E\"/__version__ = \"$NEW\"/" "$INIT_PY" || die "failed to edit $INIT_PY" got_i=$(sed -nE 's/^__version__ = "([^"]+)"/\1/p' "$INIT_PY" | head -1) - [ "$got_p" = "$NEW" ] || die "post-edit check failed: $PYPROJECT is '$got_p', expected '$NEW'" [ "$got_i" = "$NEW" ] || die "post-edit check failed: $INIT_PY is '$got_i', expected '$NEW'" - info "bumped both files to $NEW" + info "bumped $INIT_PY to $NEW" fi # --- Commit + push ------------------------------------------------------------ COMMIT_MSG="chore: bump version to ${NEW}" -run git add "$PYPROJECT" "$INIT_PY" +run git add "$INIT_PY" run git commit -m "$COMMIT_MSG" run git push -u origin "$BRANCH" # --- Open the PR into develop ------------------------------------------------- PR_TITLE="chore: bump version to ${NEW}" -PR_BODY="Bumps the package version ${CURRENT} → ${NEW} in lockstep across \`${PYPROJECT}\` and \`${INIT_PY}\`. +PR_BODY="Closes #${ISSUE} + +Bumps the package version ${CURRENT} → ${NEW} in \`${INIT_PY}\`, its one source (\`pyproject.toml\` reads it from there). Every promotion to \`main\` advances the version; this lands the bump on \`${BASE_BRANCH}\` ahead of the promotion PR (see the release conventions). From 0d81365e8341e002e186ec816655dc125fc12580 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 11:42:54 +0200 Subject: [PATCH 09/34] docs: generate API reference and config tables, add release notes (#1080) Part of #795 --- .gitattributes | 9 + CONTRIBUTING.md | 19 +- README.md | 2 +- docs/README.md | 2 +- docs/unsupported-boundary.md | 83 +- docs/verified-apis.md | 2 +- python/README.md | 40 +- python/scripts/generate_docs.py | 769 ++++++++++++++++ python/src/donkey_kit/core/cachecontrol.py | 6 +- python/src/donkey_kit/core/config.py | 10 +- python/src/donkey_kit/core/correlation.py | 6 +- .../tests/unit/test_docs_from_env_kwargs.py | 13 +- python/tests/unit/test_generated_docs.py | 154 ++++ website/README.md | 27 + website/app/layout.tsx | 12 +- website/content/_meta.js | 2 + website/content/cli.mdx | 48 +- website/content/frameworks/index.mdx | 17 + website/content/reference/_meta.js | 1 + website/content/reference/api.mdx | 485 ++++++++++ website/content/reference/configuration.mdx | 43 + .../reference/unsupported-boundary.mdx | 66 +- website/content/release-notes.mdx | 50 ++ website/content/troubleshooting.mdx | 144 +++ website/lib/version.mjs | 84 ++ website/lib/version.test.mjs | 79 ++ website/package.json | 2 +- website/public/cli.md | 46 +- website/public/frameworks.md | 15 + website/public/llms-full.txt | 837 +++++++++++++++++- website/public/llms.txt | 3 + website/public/reference/api.md | 478 ++++++++++ website/public/reference/configuration.md | 41 + .../public/reference/unsupported-boundary.md | 64 +- website/public/release-notes.md | 42 + website/public/troubleshooting.md | 136 +++ 36 files changed, 3647 insertions(+), 190 deletions(-) create mode 100644 python/scripts/generate_docs.py create mode 100644 python/tests/unit/test_generated_docs.py create mode 100644 website/content/reference/api.mdx create mode 100644 website/content/release-notes.mdx create mode 100644 website/content/troubleshooting.mdx create mode 100644 website/lib/version.mjs create mode 100644 website/lib/version.test.mjs create mode 100644 website/public/reference/api.md create mode 100644 website/public/release-notes.md create mode 100644 website/public/troubleshooting.md diff --git a/.gitattributes b/.gitattributes index 97025497..e53755a0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,15 @@ # Auto detect text files and perform LF normalization * text=auto +# Generated docs (#795): collapsed in diffs; regenerate rather than edit. +# python/scripts/generate_docs.py writes these two: +python/README.md linguist-generated=true +website/content/reference/api.mdx linguist-generated=true +# website/scripts/generate-llms.mjs writes these: +website/public/llms.txt linguist-generated=true +website/public/llms-full.txt linguist-generated=true +website/public/**/*.md linguist-generated=true + # Images are binary (#757): never normalise line endings in them, never show a # textual diff, never attempt a textual merge. SVG stays text on purpose. *.png binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7ac3ac0..66153304 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -914,15 +914,25 @@ Self-review before pushing = the pre-PR gate in Section 1 (`mypy`, `ruff check . `website/` (Nextra) describes how the SDK behaves from a consumer's perspective. When code changes what the SDK does — or which platform facts it depends on — the docs must change *with it*, or the drift is discovered by a -confused adopter instead of at review time. There is no automated drift detector; -this is a PR-time discipline. The surface→page map (code paths under +confused adopter instead of at review time. + +Some facts are generated, so they can't drift: `python scripts/generate_docs.py` +(from `python/`) writes the PyPI README (`python/README.md`, from the root +`README.md`), the settings index in `reference/configuration.mdx`, the CLI +command reference in `cli.mdx`, the adapter capabilities table in +`frameworks/index.mdx`, and the whole Python API page (`reference/api.mdx`). +Edit the code, the docstring or the root README, run the script, and commit the +result; never edit a `BEGIN GENERATED` region by hand. +`tests/unit/test_generated_docs.py` fails when a committed copy is stale, and +`--check` reports the same without writing. Everything else is a PR-time +discipline. The surface→page map (code paths under `python/src/donkey_kit/`, pages under `website/content/`): | Code surface | Docs page(s) | | --- | --- | | `core/errors.py` | `errors.mdx` | | `core/config.py`, `core/auth.py`, `core/endpoints.py` | `reference/configuration.mdx` | -| `core/_verify.py`, `docs/verified-apis.md`, `docs/unsupported-boundary.md` | `reference/unsupported-boundary.mdx`, and any page that states the changed status (`roadmap.mdx`, `frameworks/index.mdx`) | +| `core/_verify.py`, `docs/verified-apis.md` | `reference/unsupported-boundary.mdx`, and any page that states the changed status (`roadmap.mdx`, `frameworks/index.mdx`) | | `core/budget.py` | `budget.mdx` | | `core/telemetry.py`, `core/cost.py` | `telemetry.mdx` | | `core/lastcall.py` | `reference/last-call.mdx` | @@ -945,7 +955,8 @@ shape, so one surface never says "live" while another still says "planned" or - `classify()` and the related docstrings in `python/src/donkey_kit/core/` (notably `core/errors.py`); -- `docs/unsupported-boundary.md`; +- `website/content/reference/unsupported-boundary.mdx` (the one boundary list; + `docs/unsupported-boundary.md` is only a pointer to it); - `website/content/**` **and** the generated `website/public/**` copies (regenerate with `npm run generate:llms`); - the fixture index (`python/src/donkey_kit/simulator/_fixtures/rejections/README.md`); diff --git a/README.md b/README.md index 7bd1f533..be776c80 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ access — from your own agent framework, in your own IDE, without adopting Mule > as a first-party, official-status SDK. > > Licensed under [Apache-2.0](LICENSE). See -> [`docs/unsupported-boundary.md`](docs/unsupported-boundary.md) for exactly +> the [unsupported boundary](https://docs.donkey-kit.dev/reference/unsupported-boundary) for exactly > which platform APIs this SDK calls and their support classification. > > **Security.** Report vulnerabilities privately, never in a public issue. See diff --git a/docs/README.md b/docs/README.md index 4a0d4cd7..2d78ebbe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ against a real Anypoint sandbox versus what is still assumed or blocked. | File | What it is | | --- | --- | | [`verified-apis.md`](verified-apis.md) | **The verification ledger** — the single source of truth for every endpoint, header, and class name the SDK touches and its status (`VERIFIED (LIVE)` / `VERIFIED (CLI)` / `VERIFIED (plugin)` / `UNVERIFIED` / blocked). When a value is confirmed against a sandbox, flip its row here **and** replace its `Unverified(...)` placeholder in `core/_verify.py` with a plain constant — the two edits move together. | -| [`unsupported-boundary.md`](unsupported-boundary.md) | **The procurement doc** — classifies every platform API the SDK calls (public / no-SLA / undocumented), so enterprise-buyer questions get a five-minute answer instead of a two-week stall. Its "Undocumented surfaces" section is designed to stay empty. | +| [`unsupported-boundary.md`](unsupported-boundary.md) | A pointer to the procurement doc, which now lives on the site as the [unsupported boundary](https://docs.donkey-kit.dev/reference/unsupported-boundary) page (#795). That page classifies every platform API the SDK calls (public / no-SLA / undocumented). | | [`python-support.md`](python-support.md) | **The Python support policy** — which CPython versions are supported and tested, and when one is dropped (the first minor release after its end of life). | | [`adr/`](adr/README.md) | **Architecture decision records** — one file per design decision (context, decision, alternatives, consequences, status), with the template and the index in [`adr/README.md`](adr/README.md). An ADR is required for any change to an `ARCHITECTURE.md` invariant, an import-linter contract, the API stability tiers or the dependency policy (#731). | | [`releasing.md`](releasing.md) | **How a release reaches PyPI** — the Trusted Publishing (OIDC) workflows wired as `.github/workflows/publish-pypi.yml` and `.github/workflows/publish-testpypi.yml` (#206, #674), the public API surface semver governs, and the one-time human step to register the trusted publisher. The version scheme and tag convention are in its *Versioning & naming* section. | diff --git a/docs/unsupported-boundary.md b/docs/unsupported-boundary.md index 717af96e..b18354e4 100644 --- a/docs/unsupported-boundary.md +++ b/docs/unsupported-boundary.md @@ -1,77 +1,10 @@ # Unsupported boundary -Every platform API the SDK calls, classified. Enterprise buyers will ask; having -this pre-written converts a two-week procurement stall into a five-minute -conversation. Linked from the README above the fold. - -This is a **support-classification ledger**, not the verification ledger. -[`verified-apis.md`](verified-apis.md) records how each fact was established -(`VERIFIED (LIVE)`, `VERIFIED (plugin)`, and so on). This file answers a -different question: whether the shipping SDK depends on a public MuleSoft -contract, a live-observed extension with no third-party compatibility SLA, or -an undocumented surface. - -Classification: - -- **Documented and public** — safe. -- **Documented but no SLA for third-party use** — will break; we'll fix. -- **Undocumented** — should be empty. Anything here needs a written - justification and a named owner. - -| API / surface | Module | Classification | Justification / owner | -|---|---|---|---| -| Connected-app bearer-token request: `POST /accounts/api/v2/oauth2/token` | `core.auth.AnypointConnectedApp` | **Documented and public** | MuleSoft documents the client-credentials request and response in [Getting the Bearer Token for a Connected App](https://docs.mulesoft.com/access-management/connected-app-bearer-token-example). The SDK path is also `VERIFIED (plugin)` in `verified-apis.md` §1 and §12.1. | -| Model Proxy OpenAI-format data plane: caller-configured base path plus `POST /responses`, including streaming and the OpenAI `usage` object | `llm.client.LLMClient`; `core.lastcall.LastCall` | **Documented and public** | MuleSoft documents the endpoint, `client_id` / `client_secret` headers, request bodies, and streaming in [Sending Requests to Model Proxies](https://docs.mulesoft.com/general/model-proxy-request). The SDK's `/responses` path is `VERIFIED (LIVE)` in `verified-apis.md` §2. The raw `AsyncOpenAI` client can also call the documented `/chat/completions` route, but this repository has not live-probed that route. | -| Client ID Enforcement request headers and `401` rejection contract | `core.transport.proxy_auth_headers`; `core.errors.classify` | **Documented and public** | The [Client ID Enforcement policy](https://docs.mulesoft.com/gateway/latest/policies-included-client-id-enforcement) documents the default header expressions and `WWW-Authenticate: Client-ID-Enforcement`. The configured proxy contract is `VERIFIED (LIVE)` in `verified-apis.md` §2 and §4. | -| LLM Token Based Rate Limit `429` response: `x-token-limit`, `x-token-remaining`, and `x-token-reset` | `core.budget.Budget`; `core.errors.classify` | **Documented and public** | The [LLM Token Based Rate Limit policy](https://docs.mulesoft.com/gateway/latest/policies-included-llm-token-rate-limit) documents all three headers and the reset unit. The SDK's rejection fixture is `VERIFIED (LIVE)` in `verified-apis.md` §4. | -| LLM PII Detection `403` rejection body (`error.type = "pii_detected"`) | `core.errors.classify` | **Documented and public** | The [LLM PII Detection policy](https://docs.mulesoft.com/gateway/latest/policies-included-llm-pii-detection) documents the rejection body. The SDK also captured it live; see `verified-apis.md` §4. | -| Regex Prompt Guard body and Injection Protection response header | `core.errors.classify` | **Documented and public** | MuleSoft documents `matched_patterns` in [Regex Prompt Guard](https://docs.mulesoft.com/gateway/latest/policies-included-regex-prompt-guard) and `x-injection-protection: blocked` in [Injection Protection](https://docs.mulesoft.com/gateway/latest/policies-included-injection-protection). Both discriminators are now live-captured — Regex Prompt Guard 2026-09-22 (#253), Injection Protection 2026-09-27 (#669); see `verified-apis.md` §4. | -| Azure Content Safety and Amazon Bedrock Guardrails rejection headers and bodies | `core.errors.classify` | **Documented and public** | The [Azure Content Safety](https://docs.mulesoft.com/gateway/latest/policies-included-azure-content-safety) and [Amazon Bedrock Guardrails](https://docs.mulesoft.com/gateway/latest/policies-included-bedrock-guardrails) policy pages document their action/reason headers and `403` bodies. Both are now live-captured — Azure 2026-09-22, Bedrock 2026-09-24 (#568); see `verified-apis.md` §4. | -| Upstream provider error pass-through: non-`429` `4xx` nested error envelope; generic `5xx` status family | `core.errors.classify` | **Documented but no SLA for third-party use** | A real Model Proxy passed an OpenAI `400 model_not_found` envelope through verbatim, including `code`, `type`, and `param` (`VERIFIED (LIVE)`, `verified-apis.md` §4). MuleSoft's public Model Proxy page does not state a compatibility contract for provider error envelopes, whose schema ultimately belongs to the upstream provider. The SDK maps any generic `5xx` to `UpstreamModelError` by status only and assumes no body shape. Owner: DDK maintainers; retain the captured provider fixture and classify changed envelopes conservatively. | -| Successful-response budget window in `x-llm-proxy-ratelimit` prose | `core.budget.Budget` | **Documented but no SLA for third-party use** | Captured from a real Model Proxy and parsed fail-open: an absent or changed sentence is ignored, never fatal. The exact prose contract is `VERIFIED (LIVE)` in `verified-apis.md` §4 but is not stated in the public policy page. Owner: DDK maintainers; re-capture on gateway-policy changes. | -| Gateway identity and routing response metadata: `x-request-id`, `x-envoy-decorator-operation`, and `x-llm-proxy-{routing-type,routing-fallback,llm-provider,llm-model}` | `core.lastcall.LastCall`; `core.transport.DonkeyAsyncClient` | **Documented but no SLA for third-party use** | Captured from a real Model Proxy (`verified-apis.md` §2 and §3). Parsers are fail-open: missing or unrecognised values become `None`, and only an explicit `routing-fallback: true` changes retry behavior. Owner: DDK maintainers; contract changes require new fixtures before parser changes. | - - -**Contents** - -- [Deliberately outside the shipping boundary](#deliberately-outside-the-shipping-boundary) -- [Local Mode is not a supported deployment target](#local-mode-is-not-a-supported-deployment-target) -- [Undocumented surfaces](#undocumented-surfaces) - - -## Deliberately outside the shipping boundary - -- Exchange search and asset resolution, API Manager governed-state reads, MCP - discovery/binding, and Exchange publication raise - `NotImplementedError("blocked on verification: ...")` before network I/O. - They are therefore not called platform APIs and are not classified above. -- `LLMClient.list_models(live=True)` does not call `GET /models`; live capture - established that Model Proxy has no model-catalog endpoint. -- Application/business-group attribution request-header names remain - warning-emitting `Unverified` placeholders (`verified-apis.md` §3). They are - the only names here that still warn, and they have no config key, so they - cannot be overridden; leaving `application_name` / `business_group` unset - sends neither header. The gateway reads and echoes the correlation request header; the per-call - and cost-tag request-header names are overridable SDK conventions it does not - read, and cost tags are carried on spans. The SDK does not depend on the - gateway reading any of these, so they are not represented as supported - platform contracts here. - -## Local Mode is not a supported deployment target - -Omni/Flex Gateway **Local Mode** is not supported by DDK. The pure-Python -local gateway simulator (`donkey mock` / `simulate()`) is the supported local -dev loop; real-gateway behavior — the LLM Proxy, MCP Bridge, and their -policies — is exercised against a hosted (Connected Mode) proxy instead. A -stock Flex Gateway 1.14.0 Local Mode image rejects the LLM Proxy and MCP -Bridge policies as missing extensions and refuses configuration until -registered to a control plane — enough to make this scope call, and bounded -to that image version. See `verified-apis.md` §6 for the underlying findings -and #661 for the decision. - -## Undocumented surfaces - -**This section must stay empty.** If a surface lands here, it needs a written -justification and an owner, and it must be re-evaluated at every milestone. - -_(none — as required)_ +This page moved to the docs site: +****. + +The site page is the one maintained list of the platform APIs the SDK calls and +their support classification. Its source is +[`website/content/reference/unsupported-boundary.mdx`](../website/content/reference/unsupported-boundary.mdx); +edit that file, not this one (#795). How each fact was established stays in the +verification ledger, [`verified-apis.md`](verified-apis.md). diff --git a/docs/verified-apis.md b/docs/verified-apis.md index 46f966ef..2dd468f6 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -659,7 +659,7 @@ rejected as missing extensions, and the gateway will not accept configuration without control-plane registration. The pure-Python local gateway simulator (`donkey mock` / `simulate()`, BG §1.4, the `local_gateway` pytest marker) is DDK's supported local dev loop; real-gateway behavior is exercised against a -hosted (Connected Mode) proxy. See `docs/unsupported-boundary.md` for the +hosted (Connected Mode) proxy. See the site's `reference/unsupported-boundary` page for the consumer-facing statement of this same decision, and #65 for the verification questions this decision resolves as out of scope rather than answered. diff --git a/python/README.md b/python/README.md index ec3cd89d..ee63e368 100644 --- a/python/README.md +++ b/python/README.md @@ -41,7 +41,7 @@ access — from your own agent framework, in your own IDE, without adopting Mule > as a first-party, official-status SDK. > > Licensed under [Apache-2.0](https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/LICENSE). See -> [`docs/unsupported-boundary.md`](https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/docs/unsupported-boundary.md) for exactly +> the [unsupported boundary](https://docs.donkey-kit.dev/reference/unsupported-boundary) for exactly > which platform APIs this SDK calls and their support classification. > > **Security.** Report vulnerabilities privately, never in a public issue. See @@ -51,6 +51,16 @@ access — from your own agent framework, in your own IDE, without adopting Mule > dropped in the first minor release after its end of life. See > [`docs/python-support.md`](https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/docs/python-support.md). + +**Contents** + +- [Documentation](#documentation) +- [Install](#install) +- [Framework support](#framework-support) +- [What's verified (verification discipline)](#whats-verified-verification-discipline) +- [Conformance exemptions](#conformance-exemptions) + + ## Documentation Two audiences, two doc sets: @@ -108,10 +118,10 @@ held to the full conformance bar, the rest supported through the three-line `connection_kwargs()` escape hatch. Every framework below returns its framework's **own native object** — never a wrapper. -| Tier | Frameworks | What it means | +| Tier | Frameworks | Status ([`docs/verified-apis.md` §8](https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/docs/verified-apis.md)) | | --- | --- | --- | -| **Conformance-tested** | The raw client (`donkey.llm.client()`) and **LangGraph** | Held to the conformance suite in CI — the governed contract is proven end to end. | -| **Supported via `connection_kwargs()`** | Google ADK, Strands, Microsoft Agent Framework, OpenAI Agents SDK, Anthropic SDK, CrewAI, LlamaIndex | Governed kwargs verified at the `connection_kwargs()` level, not conformance-tested. | +| **Deep** | The raw client (`donkey.llm.client()`) and **LangGraph** | **Conformance-tested against the simulator** in CI. LangGraph's `ChatOpenAI` constructor is signature-confirmed offline; it has had no live round-trip. | +| **Supported via `connection_kwargs()`** | Google ADK, Strands, Microsoft Agent Framework, OpenAI Agents SDK, Anthropic SDK, CrewAI, LlamaIndex | **Signature-confirmed offline**: each factory builds its native object against the installed framework (`scripts/verify_frameworks.py`), with no live round-trip and no conformance run. The exception is ADK's `gemini()`, which is **live-verified** through a `Format=Gemini` proxy. | `connection_kwargs()` works for all eight; a second deep adapter is promoted from demand evidence, one at a time (#223/#244) — never guessed up front. See the @@ -121,15 +131,19 @@ for each. ## What's verified (verification discipline) The **LLM data plane** — governed model access through the Omni Gateway proxy — -is live-verified against a real Anypoint sandbox, and both the framework-free -client and the framework adapters are wired to that verified contract — LangGraph -is held to the conformance suite, the other seven are supported at the -`connection_kwargs()` level (see [Framework support](#framework-support)). +is live-verified against a real Anypoint sandbox. The framework-free client and +the framework adapters are wired to that contract, but the adapters themselves +are not live-verified: the raw client and LangGraph are conformance-tested +against the simulator, ADK's `gemini()` is live-verified, and every other +adapter constructor is signature-confirmed offline (see +[Framework support](#framework-support)). Everything still gated raises `NotImplementedError("blocked on verification: …")` rather than guessing at an unverified endpoint, header, or class name — that -currently includes Exchange→MCP tool discovery, Exchange publication, and the -exact framework adapter class names/kwargs. The types those blocked surfaces -use live in `donkey_kit.experimental`, outside the stable namespace. +currently includes Exchange→MCP tool discovery and Exchange publication; their +types live in `donkey_kit.experimental`, outside the stable namespace. The SDK +does not ship a provisioning control plane (ADR 0008 in `docs/adr/`). The adapters build their framework's native object directly; they +refuse only when the installed framework version lacks the class or field the +adapter depends on. The discipline behind this is documented in [`ARCHITECTURE.md` → Verification discipline](https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/ARCHITECTURE.md#verification-discipline); @@ -150,3 +164,7 @@ legitimately cannot satisfy a scenario, the reason is asserted in code | CrewAI | JWT refreshed per send | CrewAI's native OpenAI provider owns the transport and builds its own clients, so the rotating JWT the SDK adds per send never reaches its requests. `donkey.crewai.llm()` and `connection_kwargs()` raise `ConfigError` in `jwt` mode; use client-id auth with CrewAI. ADK's `model()` and `gemini()`, LlamaIndex and Microsoft Agent Framework send through the SDK's client and carry the rotating JWT on async calls ([`jwt` mode](https://docs.donkey-kit.dev/reference/configuration#jwt--model-wallet-auth-mode)). | | CrewAI | budget refusal not retried | CrewAI wraps every LLM call in its own rate-limit retry (3 attempts) and treats any `429` as a rate limit, so a `TokenBudgetExceeded` or `RequestRateLimitExceeded` refusal is sent 3 times. CrewAI has no setting to turn it off; the OpenAI client underneath has `max_retries=0`. Every other adapter sends a budget refusal once (#734). | | CrewAI | typed refusal bridged | CrewAI's native OpenAI provider owns the transport, so its errors come from a client the SDK never saw, and `donkey.run()` and `typed_refusals()` cannot tell a gateway refusal from any other failure there and pass those errors through (#724). ADK's `gemini()` and `model()` send through the SDK's transport and are bridged (conformance-tested in #955 and #969). LiteLLM, under `model()`, re-raises a refusal as its own exception, and the adapter's `llm_client` marks that exception with the refusal the SDK's transport received. | + +Each exemption matches what the adapter reports in `donkey..capabilities()` (a frozen `AdapterCapabilities` per factory), and a unit test keeps the two in step (#726). + + diff --git a/python/scripts/generate_docs.py b/python/scripts/generate_docs.py new file mode 100644 index 00000000..0d52d92d --- /dev/null +++ b/python/scripts/generate_docs.py @@ -0,0 +1,769 @@ +"""Generate the docs that restate facts the code owns (#795). + +Each class of fact has one owner. Where a page restates a fact the code +already holds, this script writes that part of the page from the code, and a +unit test (``tests/unit/test_generated_docs.py``) fails when the committed +page and the code disagree, the same way the ``docs-llms-drift`` CI job fails +on stale ``website/public`` files. + +Targets: + +* ``python/README.md`` — the PyPI description, written whole from the + repository ``README.md`` with every relative link made absolute (PyPI does + not resolve them). Edit the root ``README.md``, never this copy. +* ``website/content/reference/configuration.mdx`` — the ``settings-index`` + region: every ``DonkeyConfig`` field with its env var, config-file key and + default, from the field table in ``core/config.py``. +* ``website/content/cli.mdx`` — the ``cli-reference`` region: every visible + ``donkey`` command and option, from the typer app. Needs the ``[cli]`` extra. +* ``website/content/frameworks/index.mdx`` — the ``adapter-capabilities`` + region: each adapter's extra and each factory's ``capabilities()``, from the + ``ADAPTERS`` roster and the adapter classes. +* ``website/content/reference/api.mdx`` — the Python API reference, written + whole from the public signatures and the first paragraph of each docstring. + +A region sits between two marker comments, ``BEGIN GENERATED: `` and +``END GENERATED: ``; everything between them is replaced. + + python scripts/generate_docs.py # rewrite every target + python scripts/generate_docs.py --check # exit 1 and list stale targets + +A target whose optional dependency is not installed is skipped and named. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import importlib +import importlib.util +import inspect +import re +import sys +import typing +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parents[2] +REPO_URL = "https://github.com/Donkey-Development-Kit/donkey-development-kit" +# Reader-facing links point at the released branch (website/lib/repo.mjs). +DOCS_REF = "main" +SITE = "https://docs.donkey-kit.dev" + +_SCRIPT = "python/scripts/generate_docs.py" + + +# --- regions ----------------------------------------------------------------- + + +class GenerationError(Exception): + """A target file is missing its markers or cannot be rendered.""" + + +def replace_region(text: str, name: str, body: str) -> str: + """``text`` with the lines between the ``name`` markers replaced by ``body``. + + The marker lines themselves are kept as written, so a page chooses its own + comment syntax (```` in Markdown, ``{/* */}`` in MDX). + """ + lines = text.splitlines(keepends=True) + begin = [i for i, line in enumerate(lines) if f"BEGIN GENERATED: {name} " in line] + end = [i for i, line in enumerate(lines) if f"END GENERATED: {name} " in line] + if len(begin) != 1 or len(end) != 1 or end[0] < begin[0]: + raise GenerationError( + f"expected one 'BEGIN GENERATED: {name}' marker followed by one " + f"'END GENERATED: {name}' marker" + ) + return "".join([*lines[: begin[0] + 1], body.rstrip("\n") + "\n", *lines[end[0] :]]) + + +# --- Markdown helpers ---------------------------------------------------------- + +_CODE_SPAN = re.compile(r"(`+)(.+?)\1") +_ROLE = re.compile(r":[a-z]+:`(~?)([^`<]+?)(?:\s*<([^`>]+)>)?`") +_REF = ( + r"(?:`?BG §[\d.]+`?|§[\d.]+|#\d+(?:/#\d+)*|ADR \d+|ADR-\d+|" + r"docs/[\w./-]+(?: §[\d.]+)?|upstream gap #\d+|AC\d+|hazard #\d+|" + r"config resolution|the conformance kit|Phase \d+(?:\.\d+)?)" +) +# A parenthetical that holds nothing but references: "(BG §1.1, #195)". +_REFS = re.compile(rf"\s*\(\s*{_REF}(?:\s*[,;]\s*{_REF})*\s*\)") +# A reference closing a parenthetical that says more: "(..., #352)". +_TRAILING_REF = re.compile(rf"(?:\s*[,;]\s*{_REF})+(?=\))") +# An inline pointer into the ledger: "a governed response's docs/verified-apis.md §3 headers". +_LEDGER_MENTION = re.compile(r"\s*docs/[\w./-]+\.md §[\d.]+") +_DOCS_LINK = re.compile(rf"^Docs:\s*({re.escape(SITE)}\S*)\s*$", re.MULTILINE) + + +def _outside_code(text: str, fn: Callable[[str], str]) -> str: + """Apply ``fn`` to the parts of ``text`` that are not inline code spans.""" + out: list[str] = [] + last = 0 + for match in _CODE_SPAN.finditer(text): + out.append(fn(text[last : match.start()])) + out.append(match.group(0)) + last = match.end() + out.append(fn(text[last:])) + return "".join(out) + + +def _escape_mdx(text: str) -> str: + return re.sub(r"([{}<])", r"\\\1", text) + + +def rst_to_markdown(text: str) -> str: + """One docstring paragraph as MDX-safe Markdown on a single line. + + Sphinx roles become code spans (``:class:`~a.B``` → ```B```), double + backticks become single ones, reference-only parentheticals such as + ``(BG §1.1, #195)`` are dropped, and ``{``, ``}`` and ``<`` outside code + are escaped so MDX does not read them as JSX. + """ + text = " ".join(text.split()) + + def role(match: re.Match[str]) -> str: + tilde, title, target = match.groups() + name = (target or title).strip() + if target is None and tilde: + name = name.rsplit(".", 1)[-1] + if target is not None: + name = title.strip() + return f"`{name}`" + + text = _REFS.sub("", text) + text = _TRAILING_REF.sub("", text) + text = _LEDGER_MENTION.sub("", text) + text = _ROLE.sub(role, text) + text = re.sub(r"``(.+?)``", r"`\1`", text) + # A paragraph that introduces an example ends "...::" or "...:". + text = re.sub(r":+$", ".", text) + return _outside_code(text, _escape_mdx) + + +def _cell(text: str) -> str: + """``text`` made safe for a Markdown table cell.""" + return text.replace("|", "\\|") + + +def _table(header: Iterable[str], rows: Iterable[Iterable[str]]) -> str: + head = list(header) + lines = ["| " + " | ".join(head) + " |", "|" + "---|" * len(head)] + lines += ["| " + " | ".join(_cell(c) for c in row) + " |" for row in rows] + return "\n".join(lines) + + +def _summary(obj: object) -> str: + """The first paragraph of ``obj``'s docstring, as Markdown.""" + doc = inspect.getdoc(obj) or "" + first = doc.split("\n\n", 1)[0].strip() + return rst_to_markdown(first) if first else "" + + +def _docs_route(obj: object) -> str | None: + """The site route a ``Docs: https://docs.donkey-kit.dev/...`` line names.""" + match = _DOCS_LINK.search(inspect.getdoc(obj) or "") + if match is None: + return None + route = match.group(1)[len(SITE) :] + return route or "/" + + +# --- README.md -> python/README.md ---------------------------------------------- + +_MD_LINK = re.compile(r"(!?)\[([^\]]*)\]\(([^)\s]+)\)") +_HTML_ATTR = re.compile(r"""(\s(?:src|href)=")([^"]+)(")""") +_ABSOLUTE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|#)", re.I) +_RAW_URL = "https://raw.githubusercontent.com/Donkey-Development-Kit/donkey-development-kit" + + +def _absolute(target: str, *, image: bool) -> str: + if _ABSOLUTE.match(target): + return target + path = target.removeprefix("./") + if image: + return f"{_RAW_URL}/{DOCS_REF}/{path}" + kind = "tree" if path.split("#", 1)[0].endswith("/") else "blob" + return f"{REPO_URL}/{kind}/{DOCS_REF}/{path}" + + +def pypi_readme(root_readme: str) -> str: + """The PyPI description: the repository README with absolute links. + + Inline Markdown links and HTML ``src``/``href`` attributes are rewritten; + images go to ``raw.githubusercontent.com`` so PyPI can load them. Links in + fenced code blocks are left alone. + """ + out: list[str] = [] + fenced = False + for line in root_readme.splitlines(keepends=True): + if line.lstrip().startswith(("```", "~~~")): + fenced = not fenced + if not fenced: + line = _MD_LINK.sub( + lambda m: ( + f"{m.group(1)}[{m.group(2)}]({_absolute(m.group(3), image=bool(m.group(1)))})" + ), + line, + ) + line = _HTML_ATTR.sub( + lambda m: ( + m.group(1) + _absolute(m.group(2), image="src=" in m.group(1)) + m.group(3) + ), + line, + ) + out.append(line) + text = "".join(out) + return text.rstrip("\n") + ( + "\n\n\n" + ) + + +# --- configuration: settings index ---------------------------------------------- + + +def _default_text(value: object) -> str: + if value is None: + return "unset" + if isinstance(value, bool): + return f"`{str(value).lower()}`" + return f"`{value}`" + + +def settings_index() -> str: + """Every ``DonkeyConfig`` field from the field table, in its order.""" + from donkey_kit.core import config + + defaults = {f.name: _default_text(f.default) for f in dataclasses.fields(config.DonkeyConfig)} + # ``None`` on a header field means "send the built-in name", not "unset". + defaults.update((key, f"`{name}`") for key, name in config._HEADER_KEYS) # noqa: SLF001 + defaults["base_url"] = "from `region`" + rows = [ + ( + f"`{spec.env}`", + f"`{spec.name}`", + f"`{spec.toml_key}`", + defaults[spec.name], + ) + for spec in config._FIELDS # noqa: SLF001 (the one config table, as its test reads it) + ] + intro = ( + "Every `DonkeyConfig` field except `cost` (set per dimension, see\n" + "[Cost-attribution tags](#cost-attribution-tags)), in the order the SDK reads\n" + f"them. The config-file key goes in the `[donkey]` table of `{config.TOML_NAME}`.\n" + "This table is generated from the field table in `core/config.py`.\n" + ) + header = ("Env var", "`DonkeyConfig` field", "Config-file key", "Default") + return intro + "\n" + _table(header, rows) + + +# --- CLI reference ------------------------------------------------------------- + +_TRAILING_REFS = re.compile(r"\s*\([^()]*\)\.?$") + + +def _command_summary(help_text: str | None) -> str: + first = (help_text or "").strip().split("\n\n", 1)[0] + first = " ".join(first.split()) + first = _TRAILING_REFS.sub("", first).rstrip(".") + return rst_to_markdown(first + ".") if first else "" + + +def _option_default(param: Any) -> str: # noqa: ANN401 (a click.Parameter from typer) + if getattr(param, "is_flag", False): + return "off" if not param.default else "on" + default = param.default + if default is None or default == () or default == []: + return "none" + return f"`{default}`" + + +def _option_rows(params: Iterable[Any]) -> list[tuple[str, str, str]]: + rows: list[tuple[str, str, str]] = [] + for param in params: + if getattr(param, "hidden", False) or not param.opts: + continue + flags = ", ".join(f"`{opt}`" for opt in param.opts) + meaning = rst_to_markdown(" ".join((param.help or "").split())) + meaning = meaning[:1].upper() + meaning[1:] + if meaning and not meaning.endswith("."): + meaning += "." + if param.multiple and "repeatable" not in meaning.lower(): + meaning += " Repeatable." + rows.append((flags, _option_default(param), meaning.strip())) + return rows + + +def cli_reference() -> str: + """Every visible command of ``donkey`` and its options, from the typer app.""" + import typer.main + + import donkey_kit.cli # noqa: F401 (registers every command on the app) + from donkey_kit.cli._app import app + + group = typer.main.get_command(app) + parts = [ + "Generated from the CLI itself; `donkey --help` prints the same.", + "", + "Global options go before the command (`donkey --json init`):", + "", + _table(("Option", "Default", "Meaning"), _option_rows(group.params)), + ] + commands = getattr(group, "commands", {}) + for name in sorted(commands): + command = commands[name] + if command.hidden: + continue + parts += ["", f"`donkey {name}`: {_command_summary(command.help)}"] + rows = _option_rows(command.params) + if rows: + parts += ["", _table(("Option", "Default", "Meaning"), rows)] + else: + parts += ["", "No options of its own."] + return "\n".join(parts) + + +# --- framework adapters ---------------------------------------------------------- + +_CAPABILITY_FIELDS = ("transport", "sync", "streaming", "typed_refusals", "observes_last_call") + + +def _capability(value: object) -> str: + if isinstance(value, bool): + return "✅" if value else "❌" + return f"`{value}`" + + +def adapter_capabilities() -> str: + """Each adapter factory's ``capabilities()``, from the adapter roster.""" + from donkey_kit import integrations + + rows: list[tuple[str, ...]] = [] + for spec in integrations.ADAPTERS.values(): + module = importlib.import_module(spec.module, integrations.__name__) + cls = getattr(module, spec.cls) + for factory, caps in cls.factories.items(): + rows.append( + ( + f"`donkey.{spec.attr}.{factory}()`", + f"`[{spec.extra}]`", + *(_capability(getattr(caps, field)) for field in _CAPABILITY_FIELDS), + ) + ) + header = ("Factory", "Extra", *(f"`{field}`" for field in _CAPABILITY_FIELDS)) + return ( + "What each factory reports, generated from the adapter roster and each\n" + "adapter's declared `factories`:\n\n" + _table(header, rows) + ) + + +# --- API reference ------------------------------------------------------------- + + +def _annotation(value: object) -> str: + if value is inspect.Parameter.empty: + return "" + if isinstance(value, str): + return value + return str(inspect.formatannotation(value)) + + +def _default(value: object) -> str: + text = repr(value) + return "..." if " at 0x" in text else text + + +def _param(param: inspect.Parameter) -> str: + text = param.name + if param.kind is inspect.Parameter.VAR_POSITIONAL: + text = "*" + text + elif param.kind is inspect.Parameter.VAR_KEYWORD: + text = "**" + text + annotation = _annotation(param.annotation) + if annotation: + text += f": {annotation}" + if param.default is not param.empty: + text += f" = {_default(param.default)}" if annotation else f"={_default(param.default)}" + return text + + +def format_signature(call: str, func: Callable[..., Any], *, bound: bool) -> str: + """``call(params) -> return``, one parameter per line when it runs long. + + A coroutine function is shown as ``async call(...)``, and a function + wrapped by ``@contextmanager``/``@asynccontextmanager`` with the context + manager it returns rather than its generator's annotation. + ``bound`` drops the first parameter (``self`` or ``cls``). Annotations are + printed as written in the source, so the output does not depend on which + optional packages are installed. + """ + signature = inspect.signature(func) + params = list(signature.parameters.values())[1 if bound else 0 :] + parts: list[str] = [] + star = False + for index, param in enumerate(params): + if param.kind is param.VAR_POSITIONAL: + star = True + if param.kind is param.KEYWORD_ONLY and not star: + parts.append("*") + star = True + parts.append(_param(param)) + if param.kind is param.POSITIONAL_ONLY and ( + index + 1 == len(params) or params[index + 1].kind is not param.POSITIONAL_ONLY + ): + parts.append("/") + returns = _annotation(signature.return_annotation) + namespace = getattr(inspect.unwrap(func), "__globals__", {}) + parts = [_public_type_vars(part, namespace) for part in parts] + returns = _public_type_vars(returns, namespace) + wrapped = getattr(func, "__wrapped__", None) + if inspect.isasyncgenfunction(wrapped): + returns = _context_manager("AbstractAsyncContextManager", returns) + elif inspect.isgeneratorfunction(wrapped): + returns = _context_manager("AbstractContextManager", returns) + if inspect.iscoroutinefunction(func): + call = f"async {call}" + tail = f" -> {returns}" if returns else "" + one_line = f"{call}({', '.join(parts)}){tail}" + if len(one_line) <= 88: + return one_line + inner = "".join(f" {part},\n" for part in parts) + return f"{call}(\n{inner}){tail}" + + +_PRIVATE_NAME = re.compile(r"\b_[A-Za-z]\w*") + + +def _public_type_vars(text: str, namespace: dict[str, Any]) -> str: + """``text`` with private type-variable names shown without the underscore. + + ``Callable[_P, _R]`` reads as ``Callable[P, R]``; a private name that is not + a ``TypeVar`` or ``ParamSpec`` in the function's module is left alone. + """ + + def public(match: re.Match[str]) -> str: + name = match.group(0) + is_var = isinstance(namespace.get(name), (typing.TypeVar, typing.ParamSpec)) + return name[1:] if is_var else name + + return _PRIVATE_NAME.sub(public, text) + + +_YIELDS = re.compile(r"^(?:[\w.]+\.)?(?:Async)?(?:Iterator|Generator)\[([^,\]]+)") + + +def _context_manager(kind: str, returns: str) -> str: + """The type a ``@contextmanager`` function returns, from its generator annotation. + + ``inspect.signature`` follows ``__wrapped__`` to the generator, whose + annotation (``AsyncIterator[None]``) is not what a caller gets back. + """ + match = _YIELDS.match(returns) + return f"{kind}[{match.group(1).strip() if match else 'Any'}]" + + +@dataclass(frozen=True) +class _ClassDoc: + cls: type + #: How code reaches an instance: ``donkey``, ``donkey.llm``, ... + instance: str + title: str + intro: str = "" + #: Members left out (documented elsewhere, or not for callers). + skip: frozenset[str] = frozenset() + #: Whether to show the constructor. + constructor: bool = False + + +def _members(cls: type, skip: frozenset[str]) -> list[tuple[str, object]]: + return [ + (name, obj) + for name, obj in vars(cls).items() + if not name.startswith("_") and name not in skip and not _is_data(obj) + ] + + +def _is_data(obj: object) -> bool: + return not (isinstance(obj, (property, classmethod, staticmethod)) or inspect.isfunction(obj)) + + +def _member_section(doc: _ClassDoc, name: str, obj: object) -> list[str]: + owner = doc.cls.__name__ + if isinstance(obj, property): + assert obj.fget is not None + returns = _annotation(inspect.signature(obj.fget).return_annotation) + heading = f"`{doc.instance}.{name}`" + code = f"{doc.instance}.{name}: {returns}" if returns else f"{doc.instance}.{name}" + target: object = obj + elif isinstance(obj, classmethod): + heading = f"`{owner}.{name}()`" + code = format_signature(f"{owner}.{name}", obj.__func__, bound=True) + target = obj.__func__ + elif isinstance(obj, staticmethod): + heading = f"`{doc.instance}.{name}()`" + code = format_signature(f"{doc.instance}.{name}", obj.__func__, bound=False) + target = obj.__func__ + else: + assert callable(obj) + heading = f"`{doc.instance}.{name}()`" + code = format_signature(f"{doc.instance}.{name}", obj, bound=True) + target = obj + lines = ["", f"### {heading}", "", "```python", code, "```", ""] + summary = _summary(target) + if summary: + lines.append(summary) + route = _docs_route(target) + if route: + lines += ["", f"Guide: [`{route}`]({route})"] + return lines + + +def _class_section(doc: _ClassDoc) -> list[str]: + lines = ["", f"## {doc.title}", ""] + summary = _summary(doc.cls) + if summary: + lines.append(summary) + if doc.intro: + lines += ["", doc.intro] + if doc.constructor: + init = vars(doc.cls)["__init__"] + lines += [ + "", + "```python", + format_signature(doc.cls.__name__, init, bound=True), + "```", + ] + for name, obj in _members(doc.cls, doc.skip): + lines += _member_section(doc, name, obj) + return lines + + +def _export_summary(obj: object) -> str: + if typing.get_origin(obj) is typing.Literal: + return "One of " + ", ".join(f"`{value}`" for value in typing.get_args(obj)) + "." + return _summary(obj) + + +def _kind(obj: object) -> str: + if inspect.isclass(obj): + if issubclass(obj, BaseException): + return "exception" + return "class" + if inspect.isfunction(obj): + return "function" + return "type alias" + + +def api_reference() -> str: + """The whole ``reference/api.mdx`` page.""" + import donkey_kit + from donkey_kit import Budget, Donkey, DonkeyConfig, LastCall, LLMClient, integrations + + docs = [ + _ClassDoc( + Donkey, + "donkey", + "`Donkey`", + intro=( + "Each framework adapter is an attribute too, listed under\n" + "[Framework adapters](#framework-adapters)." + ), + constructor=True, + ), + _ClassDoc( + DonkeyConfig, + "config", + "`DonkeyConfig`", + intro=( + "Its fields are listed in [Configuration](/reference/configuration#all-settings)." + ), + ), + _ClassDoc(LLMClient, "donkey.llm", "`LLMClient` (`donkey.llm`)"), + _ClassDoc(Budget, "donkey.budget", "`Budget` (`donkey.budget`)"), + _ClassDoc( + LastCall, + "donkey.last_call", + "`LastCall` (`donkey.last_call`)", + intro="Its fields are listed in [`last_call` fields](/reference/last-call).", + ), + ] + + lines = [ + "---", + "description: The public Python API of donkey-kit — Donkey, DonkeyConfig, the model " + "client, the budget window, last_call, the adapters and every export — generated " + "from the code.", + "---", + "", + f"{{/* Generated by {_SCRIPT} from the package's signatures and docstrings. Do not", + "edit by hand: change the docstring, then run the script. */}", + "", + "# Python API reference", + "", + "The public API of `donkey-kit`, generated from its", + "signatures and docstrings. Each entry shows the first paragraph of the", + "docstring; `help()` on the object prints the rest. Everything listed here is", + "importable from `donkey_kit`.", + ] + for doc in docs: + lines += _class_section(doc) + + lines += [ + "", + "## Framework adapters", + "", + "`donkey.` imports the adapter on first use. If its framework is", + "not installed, the access raises `ImportError` with the `pip install` line.", + "See [Frameworks](/frameworks) for what each factory returns.", + "", + _table( + ("Attribute", "Install", "Factories"), + ( + ( + f"`donkey.{spec.attr}`", + f'`pip install "donkey-kit[{spec.extra}]"`', + ", ".join( + f"`{name}()`" + for name in getattr( + importlib.import_module(spec.module, integrations.__name__), + spec.cls, + ).factories + ), + ) + for spec in integrations.ADAPTERS.values() + ), + ), + ] + + exports = [(name, getattr(donkey_kit, name)) for name in donkey_kit.__all__] + errors = [(n, o) for n, o in exports if inspect.isclass(o) and issubclass(o, BaseException)] + others = [(n, o) for n, o in exports if (n, o) not in errors and not n.startswith("_")] + lines += [ + "", + "## Exceptions", + "", + "Every typed refusal and SDK error. See [Typed refusals](/errors) for when each", + "is raised.", + "", + _table( + ("Exception", "Base", "Meaning"), + ((f"`{n}`", f"`{o.__mro__[1].__name__}`", _summary(o)) for n, o in sorted(errors)), + ), + "", + "## Other exports", + "", + _table( + ("Name", "Kind", "Meaning"), + ((f"`{n}`", _kind(o), _export_summary(o)) for n, o in sorted(others)), + ), + ] + return "\n".join(lines) + "\n" + + +# --- targets --------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Target: + """One generated file or region.""" + + name: str + path: Path + #: The region's marker name, or ``None`` when the whole file is generated. + region: str | None + render: Callable[[], str] + #: Modules the renderer imports; a missing one skips the target. + needs: tuple[str, ...] = () + + def available(self) -> bool: + return all(importlib.util.find_spec(module) is not None for module in self.needs) + + def expected(self) -> str: + if self.region is None: + return self.render() + current = self.path.read_text(encoding="utf-8") + return replace_region(current, self.region, self.render()) + + +def _pypi_readme() -> str: + return pypi_readme((REPO / "README.md").read_text(encoding="utf-8")) + + +TARGETS: tuple[Target, ...] = ( + Target("pypi-readme", REPO / "python" / "README.md", None, _pypi_readme), + Target( + "settings-index", + REPO / "website" / "content" / "reference" / "configuration.mdx", + "settings-index", + settings_index, + ), + Target( + "cli-reference", + REPO / "website" / "content" / "cli.mdx", + "cli-reference", + cli_reference, + needs=("typer",), + ), + Target( + "adapter-capabilities", + REPO / "website" / "content" / "frameworks" / "index.mdx", + "adapter-capabilities", + adapter_capabilities, + ), + Target( + "api-reference", + REPO / "website" / "content" / "reference" / "api.mdx", + None, + api_reference, + ), +) + + +def stale(targets: Iterable[Target] = TARGETS) -> list[str]: + """The names of the targets whose committed text differs from the code.""" + out: list[str] = [] + for target in targets: + if not target.available(): + continue + current = target.path.read_text(encoding="utf-8") if target.path.is_file() else "" + if current != target.expected(): + out.append(target.name) + return out + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + parser.add_argument( + "--check", action="store_true", help="exit 1 if any target is stale; write nothing" + ) + args = parser.parse_args(argv) + if not (REPO / "website").is_dir(): + print("generate_docs: no website/ next to python/; run it from a checkout") + return 2 + failed: list[str] = [] + for target in TARGETS: + rel = target.path.relative_to(REPO) + if not target.available(): + print(f"skipped {target.name} ({rel}): needs {', '.join(target.needs)}") + continue + expected = target.expected() + current = target.path.read_text(encoding="utf-8") if target.path.is_file() else "" + if current == expected: + continue + if args.check: + failed.append(f"{target.name} ({rel})") + else: + target.path.write_text(expected, encoding="utf-8") + print(f"wrote {target.name} ({rel})") + if failed: + print("Stale generated docs; run: python scripts/generate_docs.py") + for name in failed: + print(f" - {name}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/src/donkey_kit/core/cachecontrol.py b/python/src/donkey_kit/core/cachecontrol.py index f1520d48..22f8f9cf 100644 --- a/python/src/donkey_kit/core/cachecontrol.py +++ b/python/src/donkey_kit/core/cachecontrol.py @@ -160,8 +160,10 @@ def current_cache_controls() -> CacheControls | None: class CacheScope: - """A **dual sync/async** context manager that binds :class:`CacheControls` to - :data:`_cache_controls` for the block (BG §1.1, #587). + """A **dual sync/async** context manager that applies :class:`CacheControls` + to the governed calls made in its block (BG §1.1, #587). + + It binds them to the :data:`_cache_controls` contextvar. This is what ``donkey.cache(...)`` returns, so the same object works under both ``with donkey.cache(...)`` and ``async with donkey.cache(...)`` — binding a diff --git a/python/src/donkey_kit/core/config.py b/python/src/donkey_kit/core/config.py index e0427db0..92835439 100644 --- a/python/src/donkey_kit/core/config.py +++ b/python/src/donkey_kit/core/config.py @@ -112,11 +112,11 @@ class ConfigOverrides(TypedDict, total=False): - """The public :class:`DonkeyConfig` fields, each optional, as keyword - arguments: what :meth:`DonkeyConfig.resolve`, :meth:`DonkeyConfig.with_overrides` - and ``Donkey.from_env`` accept, so a misspelt field fails type checking - (#716, #727). Must list exactly the dataclass's public fields; a unit test - pins that.""" + """The public :class:`DonkeyConfig` fields, each optional, as keyword arguments: + what :meth:`DonkeyConfig.resolve`, :meth:`DonkeyConfig.with_overrides` and + ``Donkey.from_env`` accept, so a misspelt field fails type checking (#716, #727). + + Must list exactly the dataclass's public fields; a unit test pins that.""" client_id: str | None client_secret: str | None diff --git a/python/src/donkey_kit/core/correlation.py b/python/src/donkey_kit/core/correlation.py index a66278a7..f105a640 100644 --- a/python/src/donkey_kit/core/correlation.py +++ b/python/src/donkey_kit/core/correlation.py @@ -73,8 +73,10 @@ def current_cost_tags() -> CostTags | None: class RunScope: - """A **dual sync/async** context manager that binds the run correlation id - to :data:`_correlation_id` for the block (BG §1.1, #195). + """A **dual sync/async** context manager that sets the run correlation id + for the governed calls made in its block (BG §1.1, #195). + + It binds the id to the :data:`_correlation_id` contextvar. This is what ``donkey.run(id=...)`` returns, so the same object works under both ``with donkey.run(...)`` and ``async with donkey.run(...)`` — binding a diff --git a/python/tests/unit/test_docs_from_env_kwargs.py b/python/tests/unit/test_docs_from_env_kwargs.py index 2eaae135..3d3e62dc 100644 --- a/python/tests/unit/test_docs_from_env_kwargs.py +++ b/python/tests/unit/test_docs_from_env_kwargs.py @@ -3,7 +3,8 @@ A snippet with a misspelt field raises ``TypeError`` for whoever copies it. ``Donkey.from_env`` takes the cost-tag shorthands, ``path`` and any ``DonkeyConfig`` field (#727); ``DonkeyConfig.resolve`` takes ``path`` and any -field; ``DonkeyConfig.from_env`` takes nothing. +field; ``DonkeyConfig.from_env`` takes nothing. Annotated parameters in a +signature block are not calls, so they are not checked. """ from __future__ import annotations @@ -26,7 +27,13 @@ "website/content/**/*.mdx", ) _CALL = re.compile(r"\b(Donkey\.from_env|DonkeyConfig\.from_env|DonkeyConfig\.resolve)\(([^()]*)\)") -_KWARG = re.compile(r"\b(\w+)\s*=") +_KWARG = re.compile(r"^\s*(\w+)\s*=(?!=)") + + +def _kwargs(args: str) -> set[str]: + # A signature block (the generated API page, #795) annotates its parameters, + # ``team: str | None = None``; only a bare ``name=`` is a keyword argument. + return {m.group(1) for part in args.split(",") if (m := _KWARG.match(part))} def _doc_files() -> list[Path]: @@ -55,7 +62,7 @@ def named(func: object) -> set[str]: for path in _doc_files(): for match in _CALL.finditer(path.read_text(encoding="utf-8")): owner, args = match.groups() - unknown = set(_KWARG.findall(args)) - accepted[owner] + unknown = _kwargs(args) - accepted[owner] if unknown: bad.append(f"{path.relative_to(_REPO)}: {match.group(0)} → {sorted(unknown)}") assert not bad, "from_env() called with kwargs it doesn't accept:\n" + "\n".join(bad) diff --git a/python/tests/unit/test_generated_docs.py b/python/tests/unit/test_generated_docs.py new file mode 100644 index 00000000..c966b420 --- /dev/null +++ b/python/tests/unit/test_generated_docs.py @@ -0,0 +1,154 @@ +"""The generated docs match the code they restate (#795). + +``scripts/generate_docs.py`` writes the PyPI README, the configuration +settings index, the CLI command reference, the adapter capabilities table and +the Python API reference page from the code. This test fails when a committed +copy is stale, the way the ``docs-llms-drift`` CI job fails on stale +``website/public`` files. The fix is always the same: run +``python scripts/generate_docs.py`` and commit the result. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "generate_docs.py" + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("ddk_generate_docs", _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module # dataclasses look their module up while loading + spec.loader.exec_module(module) + return module + + +gen = _load() + +pytestmark = pytest.mark.skipif( + not (gen.REPO / "website").is_dir(), + reason="the repository docs are not included in the sdist", +) + + +@pytest.mark.parametrize("target", gen.TARGETS, ids=[t.name for t in gen.TARGETS]) +def test_generated_doc_is_up_to_date(target: Any) -> None: + for module in target.needs: + pytest.importorskip(module) + path: Path = target.path + assert path.is_file(), f"{path} is missing; run: python scripts/generate_docs.py" + current = path.read_text(encoding="utf-8") + assert current == target.expected(), ( + f"{path.relative_to(gen.REPO)} is stale; run: python scripts/generate_docs.py" + ) + + +def test_a_region_needs_both_markers() -> None: + with pytest.raises(gen.GenerationError): + gen.replace_region("no markers here\n", "x", "body") + text = "a\n\nold\n\nb\n" + assert gen.replace_region(text, "x", "new") == ( + "a\n\nnew\n\nb\n" + ) + + +def test_pypi_readme_links_are_absolute() -> None: + src = ( + '\n' + "[License](LICENSE), [ledger](docs/verified-apis.md#4), [up](#top),\n" + "[site](https://docs.donkey-kit.dev), [dir](docs/adr/)\n" + "```\n[kept](relative.md)\n```\n" + ) + out = gen.pypi_readme(src) + blob = f"{gen.REPO_URL}/blob/main" + assert 'src="https://raw.githubusercontent.com/' in out + assert f"[License]({blob}/LICENSE)" in out + assert f"[ledger]({blob}/docs/verified-apis.md#4)" in out + assert "[up](#top)" in out + assert "[site](https://docs.donkey-kit.dev)" in out + assert f"[dir]({gen.REPO_URL}/tree/main/docs/adr/)" in out + assert "[kept](relative.md)" in out + + +def test_docstring_markup_becomes_mdx_safe_markdown() -> None: + text = gen.rst_to_markdown( + "Use :class:`~donkey_kit.core.errors.ConfigError` with ``x={a}`` and " + "a tag (BG §1.1, #195), or (`BG §1.2`) here::" + ) + assert text == "Use `ConfigError` with `x={a}` and a \\ tag, or here." + + +def test_signatures_print_string_annotations_and_mask_sentinels() -> None: + sentinel = object() + + def f(self: object, a: int, *, b: str | None = None, c: object = sentinel) -> bool: + return True + + assert ( + gen.format_signature("x.f", f, bound=True) + == "x.f(a: int, *, b: str | None = None, c: object = ...) -> bool" + ) + + +def test_signatures_mark_coroutines_and_context_managers() -> None: + import contextlib + from collections.abc import AsyncIterator, Iterator + + class C: + async def close(self) -> None: ... + + @contextlib.asynccontextmanager + async def pace(self, *, reserve: float = 0.0) -> AsyncIterator[None]: + yield + + @contextlib.contextmanager + def scope(self) -> Iterator[int]: + yield 1 + + assert gen.format_signature("c.close", C.close, bound=True) == "async c.close() -> None" + assert gen.format_signature("c.pace", C.pace, bound=True) == ( + "c.pace(*, reserve: float = 0.0) -> AbstractAsyncContextManager[None]" + ) + assert gen.format_signature("c.scope", C.scope, bound=True) == ( + "c.scope() -> AbstractContextManager[int]" + ) + + +def test_api_reference_shows_async_members() -> None: + page = gen.api_reference() + assert "async donkey.aclose() -> None" in page + assert "async donkey.llm.list_models(" in page + assert "AbstractAsyncContextManager[None]" in page + assert "AsyncIterator" not in page + + +def test_signatures_show_private_type_variables_without_the_underscore() -> None: + page = gen.api_reference() + assert "Callable[P, R]" in page + assert "_Callable" not in page and "_P," not in page + + +def test_settings_index_shows_built_in_header_names() -> None: + rows = { + line.split("|")[1].strip(): line.split("|")[4].strip() + for line in gen.settings_index().splitlines() + if line.startswith("| `") + } + assert rows["`DONKEY_CORRELATION_HEADER`"] == "`X-Correlation-Id`" + assert rows["`ANYPOINT_BASE_URL`"] == "from `region`" + assert "unset" not in {rows[f"`DONKEY_COST_{d}_HEADER`"] for d in ("TEAM", "ENDUSER")} + + +def test_api_reference_does_not_restate_the_version() -> None: + import donkey_kit + + # The version has two owners (pyproject.toml, __init__.py) and the site + # banner shows it; a third copy here would go stale on every bump. + assert f"`donkey-kit` {donkey_kit.__version__}" not in gen.api_reference() diff --git a/website/README.md b/website/README.md index 08993027..82c8eeb7 100644 --- a/website/README.md +++ b/website/README.md @@ -62,6 +62,33 @@ diff, so the committed artifacts cannot drift from the pages. npm run generate:llms # regenerate after editing pages; commit the result ``` +The artifacts stay committed on purpose (#795). Every PR diff shows how a page +edit changes what assistants read, the drift job checks them without a build, +and `.gitattributes` marks them `linguist-generated` so GitHub collapses them +in review. + +## Generated from the SDK code + +Some pages restate facts that live in the Python code. Those parts are written +by [`python/scripts/generate_docs.py`](../python/scripts/generate_docs.py), not +by hand: + +- `content/reference/api.mdx`, the whole Python API page, from the public + docstrings and signatures; +- the `BEGIN GENERATED` regions in `content/reference/configuration.mdx` + (settings index), `content/cli.mdx` (command reference) and + `content/frameworks/index.mdx` (adapter capabilities). + +Edit the code or docstring, then run the script from `python/` and commit the +result. The unit test `tests/unit/test_generated_docs.py` fails when a +committed copy is stale. The prose around a region is hand-written as usual. + +The version banner at the top of every page is built at build time by +[`lib/version.mjs`](lib/version.mjs): it reads the version from +`../python/pyproject.toml`, and asks PyPI for the latest release with a +3-second timeout. If PyPI can't be reached, the banner shows only the +documented version. + ## Deploy — GitHub Pages The site is published by [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 68da6032..981ebdfb 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -1,12 +1,14 @@ import type { Metadata } from 'next' import { Play } from 'next/font/google' import { Footer, Layout, Navbar } from 'nextra-theme-docs' -import { Head } from 'nextra/components' +import Link from 'next/link' +import { Banner, Head } from 'nextra/components' import { getPageMap } from 'nextra/page-map' import 'nextra-theme-docs/style.css' import { cloudflareBeacon } from '../lib/analytics.mjs' import { EDIT_REF, REPO_URL } from '../lib/repo.mjs' import { OG_IMAGE, SITE_NAME, siteBase } from '../lib/site.mjs' +import { documentedVersion, latestPypiVersion, versionBanner } from '../lib/version.mjs' import '../styles/globals.css' const SITE_DESCRIPTION = @@ -131,6 +133,13 @@ export default async function RootLayout({ children: React.ReactNode }) { const pageMap = await getPageMap() + // Read once at build time: the static export ships the result (#795). + const version = versionBanner(await documentedVersion(), await latestPypiVersion()) + const banner = version && ( + + {version.text} Release notes + + ) return (

+ {page.badge && ( +

+ {page.label} +

+ )}
diff --git a/website/content/a2a.mdx b/website/content/a2a.mdx index 1f379cc4..5c067b94 100644 --- a/website/content/a2a.mdx +++ b/website/content/a2a.mdx @@ -1,14 +1,12 @@ --- description: Make your agent callable by other agents over A2A — serve it locally, expose it through the gateway, and develop against a gateway on your laptop. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # A2A agents -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. Three steps take an agent from "runs on my laptop" to "callable by other agents, diff --git a/website/content/budget.mdx b/website/content/budget.mdx index 9d5863d4..16ba21f6 100644 --- a/website/content/budget.mdx +++ b/website/content/budget.mdx @@ -1,5 +1,6 @@ --- description: Remaining token budget as a first-class object, with pacing that slows down before it hits the wall. +status: live --- import { Callout } from 'nextra/components' @@ -7,8 +8,6 @@ import { Badge } from '../components' # Budget & pacing -Live - Once a token-rate-limit policy is applied, the governed proxy reports your token budget on its responses, in **two shapes** depending on the response: diff --git a/website/content/cli.mdx b/website/content/cli.mdx index 8732e542..90d22939 100644 --- a/website/content/cli.mdx +++ b/website/content/cli.mdx @@ -1,5 +1,6 @@ --- description: The @donkey.governed and @donkey.tool decorators, and the donkey CLI — init, doctor, mock, and test. +status: live --- import { Callout } from 'nextra/components' @@ -7,8 +8,6 @@ import { Badge, Output } from '../components' # CLI & decorators -Live - Two on-ramps to the SDK: decorators that govern a function in one line, and a four-command CLI for setup, diagnosis, local simulation, and conformance testing. diff --git a/website/content/community/contribute.mdx b/website/content/community/contribute.mdx index e2f56e98..99bfa829 100644 --- a/website/content/community/contribute.mdx +++ b/website/content/community/contribute.mdx @@ -1,5 +1,7 @@ --- description: How to contribute to DDK — issues, docs, examples and code — from filing an issue to a merged pull request. +status: live +statusBadge: false --- import { Callout, Steps } from 'nextra/components' diff --git a/website/content/community/team.mdx b/website/content/community/team.mdx index e50f411d..75a0721b 100644 --- a/website/content/community/team.mdx +++ b/website/content/community/team.mdx @@ -1,5 +1,7 @@ --- description: The people behind the Donkey Development Kit — its creators and contributors. +status: live +statusBadge: false --- import { Callout } from 'nextra/components' diff --git a/website/content/errors.mdx b/website/content/errors.mdx index 00553c45..f5efecd0 100644 --- a/website/content/errors.mdx +++ b/website/content/errors.mdx @@ -1,14 +1,12 @@ --- description: Typed exceptions for every governance refusal, each with a discriminator, a retry rule, and a next step. +status: live --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # Governed error taxonomy -Live - The proxy doesn't just pass model calls through — it enforces policy. When it rejects a call, DDK turns the response into a **typed exception** so you branch on the governance outcome instead of parsing bodies. diff --git a/website/content/examples/adk.mdx b/website/content/examples/adk.mdx index e4855c00..e3348019 100644 --- a/website/content/examples/adk.mdx +++ b/website/content/examples/adk.mdx @@ -1,5 +1,6 @@ --- description: Two Google ADK scripts — a LiteLlm model from donkey.adk.model() in an InMemoryRunner, and a PII refusal raised outside donkey.run(), where it stays LiteLLM's error. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/agent-framework.mdx b/website/content/examples/agent-framework.mdx index e62910b7..04917d77 100644 --- a/website/content/examples/agent-framework.mdx +++ b/website/content/examples/agent-framework.mdx @@ -1,5 +1,6 @@ --- description: Three Microsoft Agent Framework scripts — an OpenAIChatClient built by donkey.agent_framework, typed refusals unwrapped from ChatClientException, and the local simulator. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/anthropic.mdx b/website/content/examples/anthropic.mdx index ce6d02ce..3556de3d 100644 --- a/website/content/examples/anthropic.mdx +++ b/website/content/examples/anthropic.mdx @@ -1,5 +1,6 @@ --- description: Two scripts for the native anthropic client from donkey.anthropic.client() — /v1/messages on a Format=Anthropic proxy with last_call, and typed refusals simulated in-process. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/crewai.mdx b/website/content/examples/crewai.mdx index 5728b996..80d8fbea 100644 --- a/website/content/examples/crewai.mdx +++ b/website/content/examples/crewai.mdx @@ -1,5 +1,6 @@ --- description: Two CrewAI scripts — donkey.crewai.llm() for a direct call and a one-agent crew, and typed refusals from CrewAI's native OpenAI provider. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/gemini.mdx b/website/content/examples/gemini.mdx index 7ec1737b..6c4c68a2 100644 --- a/website/content/examples/gemini.mdx +++ b/website/content/examples/gemini.mdx @@ -1,5 +1,6 @@ --- description: Two plain-httpx scripts against a Format=Gemini proxy — native generateContent, and an OpenAI-shaped request classified as UpstreamRequestError. +status: offline-verified --- # Gemini diff --git a/website/content/examples/general/budget-and-pacing.mdx b/website/content/examples/general/budget-and-pacing.mdx index 14763aab..cf951ab1 100644 --- a/website/content/examples/general/budget-and-pacing.mdx +++ b/website/content/examples/general/budget-and-pacing.mdx @@ -1,5 +1,6 @@ --- description: The gateway's token window as an object, pace(reserve=) refusing locally before a request would cross your reserve, and wait_for_reset(). +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/conformance.mdx b/website/content/examples/general/conformance.mdx index 2f556d44..103ecef6 100644 --- a/website/content/examples/general/conformance.mdx +++ b/website/content/examples/general/conformance.mdx @@ -1,5 +1,6 @@ --- description: pytest --donkey-conformance grading an agent's refusal handling, budget retries and correlation logging — first a naive agent, then the fixed one, then exemptions. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/framework-objects.mdx b/website/content/examples/general/framework-objects.mdx index 8c004a08..b9d32a8f 100644 --- a/website/content/examples/general/framework-objects.mdx +++ b/website/content/examples/general/framework-objects.mdx @@ -1,5 +1,6 @@ --- description: Framework adapters that return your framework's own objects, connection_kwargs() for the rest, local model capability handles, and config validation that reports everything at once. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/gateway-identity.mdx b/website/content/examples/general/gateway-identity.mdx index 193f40aa..d574f631 100644 --- a/website/content/examples/general/gateway-identity.mdx +++ b/website/content/examples/general/gateway-identity.mdx @@ -1,5 +1,6 @@ --- description: donkey.last_call on a successful call — which gateway served it, what it routed to, per-call token usage — and on_model_substitution="raise" turning a silent model swap into ModelSubstituted. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/governed-client.mdx b/website/content/examples/general/governed-client.mdx index 1da72928..1059e4fa 100644 --- a/website/content/examples/general/governed-client.mdx +++ b/website/content/examples/general/governed-client.mdx @@ -1,5 +1,6 @@ --- description: A stock OpenAI client next to a governed one, the same PII refusal through both, and the @donkey.governed / @donkey.tool decorators. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/langgraph-agent.mdx b/website/content/examples/general/langgraph-agent.mdx index 482d8e47..60cfe364 100644 --- a/website/content/examples/general/langgraph-agent.mdx +++ b/website/content/examples/general/langgraph-agent.mdx @@ -1,5 +1,6 @@ --- description: A real LangGraph tool-calling agent governed end to end — LangChain's own ChatOpenAI, one run id around the loop, and typed refusals out of astream. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/simulating-refusals.mdx b/website/content/examples/general/simulating-refusals.mdx index 4ae58a41..3e5b74b1 100644 --- a/website/content/examples/general/simulating-refusals.mdx +++ b/website/content/examples/general/simulating-refusals.mdx @@ -1,5 +1,6 @@ --- description: Run the refusal branch of your agent without a gateway — donkey.simulate() in-process, and donkey mock --scenario for a stock client. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/telemetry.mdx b/website/content/examples/general/telemetry.mdx index 840d88d3..311e9076 100644 --- a/website/content/examples/general/telemetry.mdx +++ b/website/content/examples/general/telemetry.mdx @@ -1,5 +1,6 @@ --- description: OpenTelemetry GenAI spans with no instrumentation code, one correlation id per donkey.run(), refusal spans, and zero-config OTLP export. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/general/typed-refusals.mdx b/website/content/examples/general/typed-refusals.mdx index 90020f9e..530a12c9 100644 --- a/website/content/examples/general/typed-refusals.mdx +++ b/website/content/examples/general/typed-refusals.mdx @@ -1,5 +1,6 @@ --- description: Every captured gateway rejection shape mapped to a typed exception with classify(), live refusals on a blocking client, and GatewayUnavailable when nothing answers. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/index.mdx b/website/content/examples/index.mdx index 7c213807..0dd6a39f 100644 --- a/website/content/examples/index.mdx +++ b/website/content/examples/index.mdx @@ -1,5 +1,6 @@ --- description: Runnable DDK examples from the companion demos repo — narrative demos that run offline against the local simulator, and short per-framework scripts for a live gateway. +status: live --- import { Callout, Steps } from 'nextra/components' diff --git a/website/content/examples/langgraph.mdx b/website/content/examples/langgraph.mdx index 2e4d0140..c5fbfa55 100644 --- a/website/content/examples/langgraph.mdx +++ b/website/content/examples/langgraph.mdx @@ -1,5 +1,6 @@ --- description: Nine LangGraph scripts — a real ChatOpenAI on /chat/completions, typed refusals out of create_agent, the run id reaching tools, streaming, spans and the local simulator. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/llamaindex.mdx b/website/content/examples/llamaindex.mdx index 6a52d104..1940c90b 100644 --- a/website/content/examples/llamaindex.mdx +++ b/website/content/examples/llamaindex.mdx @@ -1,5 +1,6 @@ --- description: Two LlamaIndex scripts — an OpenAILike from donkey.llamaindex.llm() for complete() and chat(), and typed refusals from its unchanged openai errors. +status: offline-verified --- # LlamaIndex diff --git a/website/content/examples/openai-agents.mdx b/website/content/examples/openai-agents.mdx index 063fcf1e..d458704d 100644 --- a/website/content/examples/openai-agents.mdx +++ b/website/content/examples/openai-agents.mdx @@ -1,5 +1,6 @@ --- description: Three OpenAI Agents SDK scripts — an OpenAIResponsesModel on the governed client, a tool-calling Runner, typed refusals and the local simulator. +status: offline-verified --- import { Output } from '../../components' diff --git a/website/content/examples/openai.mdx b/website/content/examples/openai.mdx index 24bc4ede..87994ce9 100644 --- a/website/content/examples/openai.mdx +++ b/website/content/examples/openai.mdx @@ -1,5 +1,6 @@ --- description: Sixteen straight-line scripts for the stock openai client governed by donkey.openai() — last_call, typed refusals, budget pacing, spans, decorators, streaming, guardrails, JWT wallets and the local simulator. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/examples/strands.mdx b/website/content/examples/strands.mdx index ea1a600a..8c6d5f2f 100644 --- a/website/content/examples/strands.mdx +++ b/website/content/examples/strands.mdx @@ -1,5 +1,6 @@ --- description: Four Strands Agents scripts — OpenAIModel on a pre-built governed client, the run id reaching a tool, typed refusals simulated and live. +status: offline-verified --- import { Callout } from 'nextra/components' diff --git a/website/content/feature-overview.mdx b/website/content/feature-overview.mdx index 15e8dd99..d8958345 100644 --- a/website/content/feature-overview.mdx +++ b/website/content/feature-overview.mdx @@ -1,5 +1,6 @@ --- description: Every DDK capability at a glance — its purpose, its goal, and what it saves developers — grouped by pillar. +status: live --- import { Badge, Card, CardGrid, Figure } from '../components' @@ -25,7 +26,7 @@ attaches there once, so you never wire it call by call. ## Model access - + **Purpose:** point any of eight agent frameworks at your governed Omni Gateway proxy. **Advantage:** you get your framework's own object — `ChatOpenAI`, `LiteLlm`, `OpenAIModel`, `crewai.BaseLLM` … — with credentials, diff --git a/website/content/frameworks/adk.mdx b/website/content/frameworks/adk.mdx index d0753c93..18519643 100644 --- a/website/content/frameworks/adk.mdx +++ b/website/content/frameworks/adk.mdx @@ -1,5 +1,6 @@ --- description: Use a governed LiteLlm model, or ADK's native Gemini model on a Format=Gemini proxy, in Google's Agent Development Kit (ADK), pointed at your Omni Gateway LLM proxy. +status: offline-verified --- import { Tabs } from 'nextra/components' diff --git a/website/content/frameworks/agent-framework.mdx b/website/content/frameworks/agent-framework.mdx index 7b1850ae..15fd3200 100644 --- a/website/content/frameworks/agent-framework.mdx +++ b/website/content/frameworks/agent-framework.mdx @@ -1,5 +1,6 @@ --- description: Use a governed OpenAIChatClient in Microsoft Agent Framework, pointed at your Omni Gateway LLM proxy, with policy middleware that ends a run on a typed governance refusal. +status: offline-verified --- import { Tabs } from 'nextra/components' diff --git a/website/content/frameworks/anthropic.mdx b/website/content/frameworks/anthropic.mdx index 455f7727..a37d96af 100644 --- a/website/content/frameworks/anthropic.mdx +++ b/website/content/frameworks/anthropic.mdx @@ -1,5 +1,6 @@ --- description: Use a governed AsyncAnthropic client with the Anthropic SDK, pointed at a Format=Anthropic Omni Gateway LLM proxy with full header and transport injection. +status: offline-verified --- import { Callout, Tabs } from 'nextra/components' diff --git a/website/content/frameworks/crewai.mdx b/website/content/frameworks/crewai.mdx index 6040d026..aaca7ddb 100644 --- a/website/content/frameworks/crewai.mdx +++ b/website/content/frameworks/crewai.mdx @@ -1,5 +1,6 @@ --- description: Use a governed CrewAI LLM, served by CrewAI's native OpenAI provider and pointed at your Omni Gateway LLM proxy. +status: offline-verified --- import { Tabs } from 'nextra/components' diff --git a/website/content/frameworks/index.mdx b/website/content/frameworks/index.mdx index 7c7c9cf1..894ce6df 100644 --- a/website/content/frameworks/index.mdx +++ b/website/content/frameworks/index.mdx @@ -1,5 +1,6 @@ --- description: Governed model access from eight agent frameworks. Each adapter returns the framework's own native model or client object, pointed at your Omni Gateway LLM proxy. +status: offline-verified --- import { Callout } from 'nextra/components' @@ -80,7 +81,8 @@ export DONKEY_LLM_PROXY_URL=… DONKEY_LLM_PROXY_CLIENT_ID=… DONKEY_LLM_PROX ```python from donkey_kit import Donkey async with Donkey.from_env() as donkey: - model = donkey..("gpt-4o") # native object at the proxy + # donkey..(...), here LangGraph's chat_model: + model = donkey.langgraph.chat_model("gpt-4o") # native object at the proxy ``` Each framework page shows the factory name, the native class you get back, the diff --git a/website/content/frameworks/langgraph.mdx b/website/content/frameworks/langgraph.mdx index 9646adec..7e4e2b72 100644 --- a/website/content/frameworks/langgraph.mdx +++ b/website/content/frameworks/langgraph.mdx @@ -1,5 +1,6 @@ --- description: Use a governed LangChain ChatOpenAI in LangGraph, pointed at your Omni Gateway LLM proxy with full header and transport injection, typed refusals, and conformance testing. +status: offline-verified --- import { Tabs } from 'nextra/components' diff --git a/website/content/frameworks/llamaindex.mdx b/website/content/frameworks/llamaindex.mdx index a7ccb7ec..74484579 100644 --- a/website/content/frameworks/llamaindex.mdx +++ b/website/content/frameworks/llamaindex.mdx @@ -1,5 +1,6 @@ --- description: Use a governed OpenAILike LLM in LlamaIndex, pointed at your Omni Gateway LLM proxy with is_chat_model forced on. +status: offline-verified --- import { Tabs } from 'nextra/components' diff --git a/website/content/frameworks/openai.mdx b/website/content/frameworks/openai.mdx index 3ba3350e..20614462 100644 --- a/website/content/frameworks/openai.mdx +++ b/website/content/frameworks/openai.mdx @@ -1,5 +1,6 @@ --- description: Use a governed OpenAIChatCompletionsModel in the OpenAI Agents SDK, backed by a pre-built AsyncOpenAI client pointed at your Omni Gateway LLM proxy. +status: offline-verified --- import { Tabs } from 'nextra/components' diff --git a/website/content/frameworks/strands.mdx b/website/content/frameworks/strands.mdx index a17a844d..798e43a5 100644 --- a/website/content/frameworks/strands.mdx +++ b/website/content/frameworks/strands.mdx @@ -1,5 +1,6 @@ --- description: Use a governed OpenAIModel in Strands Agents, pointed at your Omni Gateway LLM proxy with full header and transport injection. +status: offline-verified --- import { Tabs } from 'nextra/components' diff --git a/website/content/hitl.mdx b/website/content/hitl.mdx index c4ed5cdc..075c93c5 100644 --- a/website/content/hitl.mdx +++ b/website/content/hitl.mdx @@ -1,14 +1,12 @@ --- description: One vocabulary for pause-and-ask-a-human, mapped onto each framework's native interrupt. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # Human-in-the-loop -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. diff --git a/website/content/identity.mdx b/website/content/identity.mdx index f30b4406..9d4a809b 100644 --- a/website/content/identity.mdx +++ b/website/content/identity.mdx @@ -1,14 +1,12 @@ --- description: On-behalf-of token exchange, so the gateway can enforce per-user policy instead of seeing only the service. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # Identity -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. diff --git a/website/content/index.mdx b/website/content/index.mdx index 093cf0b5..eed27271 100644 --- a/website/content/index.mdx +++ b/website/content/index.mdx @@ -1,5 +1,7 @@ --- description: Governed by the gateway, understood by your code. DDK brings Agent Fabric and Omni Gateway awareness into your agent framework — typed refusals, budgets, telemetry and simulation, with native framework objects. +status: live +statusBadge: false --- import { Callout, Tabs } from 'nextra/components' diff --git a/website/content/policies.mdx b/website/content/policies.mdx index 6052264a..a6548581 100644 --- a/website/content/policies.mdx +++ b/website/content/policies.mdx @@ -1,14 +1,12 @@ --- description: Read the in-force policy set to avoid calls that will be refused — advisory only; the gateway always wins. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # Policy handshake -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. It depends on a policy-discovery endpoint on the gateway, which does not exist today. diff --git a/website/content/publishing.mdx b/website/content/publishing.mdx index d7e4b3d1..90eda0b1 100644 --- a/website/content/publishing.mdx +++ b/website/content/publishing.mdx @@ -1,14 +1,12 @@ --- description: Derive a manifest and agent card from your code, then register them with Exchange / Agent Registry from CI — the Agent Scanner for your git repo. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # Scan & publish -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. `donkey scan` walks your repository, finds everything marked diff --git a/website/content/quickstart.mdx b/website/content/quickstart.mdx index 0b5b6b3f..b3f8095b 100644 --- a/website/content/quickstart.mdx +++ b/website/content/quickstart.mdx @@ -1,5 +1,6 @@ --- description: From pip install to a governed model call, a typed refusal, a budget and an OpenTelemetry span — locally, with no gateway and no credentials. +status: live --- import { Callout, Steps } from 'nextra/components' diff --git a/website/content/reference/api.mdx b/website/content/reference/api.mdx index 6ffca388..20166464 100644 --- a/website/content/reference/api.mdx +++ b/website/content/reference/api.mdx @@ -1,5 +1,6 @@ --- description: The public Python API of donkey-kit — Donkey, DonkeyConfig, the model client, the budget window, last_call, the adapters and every export — generated from the code. +status: live --- {/* Generated by python/scripts/generate_docs.py from the package's signatures and docstrings. Do not @@ -19,7 +20,7 @@ The SDK entry point: one governed handle on Agent Fabric. Each framework adapter is an attribute too, listed under [Framework adapters](#framework-adapters). -```python +```python nocheck Donkey( config: DonkeyConfig | None = None, *, @@ -30,7 +31,7 @@ Donkey( ### `Donkey.from_env()` -```python +```python nocheck Donkey.from_env( *, team: str | None = None, @@ -48,7 +49,7 @@ Guide: [`/reference/configuration#precedence`](/reference/configuration#preceden ### `donkey.config` -```python +```python nocheck donkey.config: DonkeyConfig ``` @@ -58,7 +59,7 @@ Guide: [`/reference/configuration`](/reference/configuration) ### `donkey.llm` -```python +```python nocheck donkey.llm: LLMClient ``` @@ -68,7 +69,7 @@ Guide: [`/quickstart`](/quickstart) ### `donkey.budget` -```python +```python nocheck donkey.budget: Budget ``` @@ -76,7 +77,7 @@ The token-budget window for this Donkey, updated in-band from every response's ` ### `donkey.last_call` -```python +```python nocheck donkey.last_call: LastCall ``` @@ -84,7 +85,7 @@ The gateway's own metadata about the most recent governed model call in this con ### `donkey.openai()` -```python +```python nocheck donkey.openai(*, sync: bool = False, **kw: Any) -> AsyncOpenAI | OpenAI ``` @@ -92,7 +93,7 @@ The headline two-line ergonomic: a native OpenAI client pointed at the governed ### `donkey.http_client()` -```python +```python nocheck donkey.http_client(*, sync: bool = False) -> DonkeyAsyncClientView | DonkeyClientView ``` @@ -100,7 +101,7 @@ The governed data-plane `httpx` client, for a framework or SDK that takes one (` ### `donkey.registry` -```python +```python nocheck donkey.registry: ExchangeRegistry ``` @@ -110,7 +111,7 @@ Guide: [`/tool-access/discovery`](/tool-access/discovery) ### `donkey.tools` -```python +```python nocheck donkey.tools: ToolsFacade ``` @@ -120,7 +121,7 @@ Guide: [`/tool-access`](/tool-access) ### `donkey.run()` -```python +```python nocheck donkey.run( id: str | None = None, *, @@ -136,7 +137,7 @@ Group one logical agent run under a shared correlation id. ### `donkey.run_context()` -```python +```python nocheck donkey.run_context(run_id: str | None = None) -> RunScope ``` @@ -144,7 +145,7 @@ Deprecated alias for `run`: use `donkey.run(id=…)`. Emits a `DeprecationWarnin ### `donkey.cache()` -```python +```python nocheck donkey.cache( *, skip: bool | None = None, @@ -159,7 +160,7 @@ Steer the gateway's **semantic cache** for every governed model call in the bloc ### `donkey.governed()` -```python +```python nocheck donkey.governed( func: Callable[P, R] | None = None, *, @@ -175,7 +176,7 @@ Wrap a callable so its body runs inside a `donkey.run()` scope. ### `donkey.tool()` -```python +```python nocheck donkey.tool(func: Callable) -> Callable ``` @@ -183,7 +184,7 @@ Mark a callable as a governed tool **without changing call behaviour**, recordin ### `donkey.simulate()` -```python +```python nocheck donkey.simulate( error: type[DonkeyError], *, @@ -195,7 +196,7 @@ Inject a real gateway refusal in-process, no server. ### `donkey.aclose()` -```python +```python nocheck async donkey.aclose() -> None ``` @@ -203,7 +204,7 @@ Close every transport this Donkey owns: the data-plane and control-plane clients ### `donkey.close()` -```python +```python nocheck donkey.close() -> None ``` @@ -217,7 +218,7 @@ Its fields are listed in [Configuration](/reference/configuration#all-settings). ### `DonkeyConfig.resolve()` -```python +```python nocheck DonkeyConfig.resolve( *, path: str | os.PathLike[str] | None = None, @@ -231,7 +232,7 @@ Guide: [`/reference/configuration#precedence`](/reference/configuration#preceden ### `DonkeyConfig.from_env()` -```python +```python nocheck DonkeyConfig.from_env() -> DonkeyConfig ``` @@ -239,7 +240,7 @@ Build from env + the optional config files: `resolve` with no arguments (see the ### `config.control_plane_url` -```python +```python nocheck config.control_plane_url: str ``` @@ -247,7 +248,7 @@ The Anypoint control-plane base URL — explicit override or region. ### `config.source_of()` -```python +```python nocheck config.source_of(name: str) -> ConfigSource ``` @@ -255,7 +256,7 @@ Where field `name` was resolved from. `explicit` (set in code) when `resolve` di ### `config.with_overrides()` -```python +```python nocheck config.with_overrides(**kw: Unpack[ConfigOverrides]) -> DonkeyConfig ``` @@ -263,7 +264,7 @@ Return a copy with the given fields replaced. ### `config.validated()` -```python +```python nocheck config.validated(*, need: Capability = 'control_plane') -> DonkeyConfig ``` @@ -271,7 +272,7 @@ Return self if valid for the requested capability, else raise a `ConfigError` li ### `config.missing_fields()` -```python +```python nocheck config.missing_fields(*, need: Capability) -> list[str] ``` @@ -279,7 +280,7 @@ Every required field for `need` that is unset, each with the env var that sets i ### `config.check_endpoints()` -```python +```python nocheck config.check_endpoints(*, need: Capability, code_credential: str | None = None) -> None ``` @@ -291,7 +292,7 @@ The framework-free proxy client factory. ### `donkey.llm.close()` -```python +```python nocheck donkey.llm.close() -> None ``` @@ -299,7 +300,7 @@ Close the blocking client built by this standalone factory, if any. ### `donkey.llm.aclose()` -```python +```python nocheck async donkey.llm.aclose() -> None ``` @@ -307,7 +308,7 @@ Close this factory's owned blocking client in an async scope. ### `donkey.llm.client()` -```python +```python nocheck donkey.llm.client(*, sync: bool = False, **kw: Any) -> AsyncOpenAI | OpenAI ``` @@ -315,7 +316,7 @@ An OpenAI client pointed at the LLM proxy, using our shared http client so heade ### `donkey.llm.list_models()` -```python +```python nocheck async donkey.llm.list_models(*, live: bool = False) -> list[ModelHandle] ``` @@ -323,7 +324,7 @@ List logical models the proxy exposes. ### `donkey.llm.resolve()` -```python +```python nocheck donkey.llm.resolve(model_id: str, *, provider: str | None = None) -> ModelHandle ``` @@ -335,7 +336,7 @@ The token-budget window for one `Donkey`, updated in-band from each response's b ### `donkey.budget.observe()` -```python +```python nocheck donkey.budget.observe(response: httpx.Response, *, now: datetime | None = None) -> None ``` @@ -343,7 +344,7 @@ Update from a response's budget headers. ### `donkey.budget.pace()` -```python +```python nocheck donkey.budget.pace( *, reserve: float = 0.0, @@ -355,7 +356,7 @@ Guard a request so it is refused *before* it crosses your reserve, not after a 4 ### `donkey.budget.wait_for_reset()` -```python +```python nocheck async donkey.budget.wait_for_reset( *, window: str | None = None, @@ -373,7 +374,7 @@ Its fields are listed in [`last_call` fields](/reference/last-call). ### `donkey.last_call.observed` -```python +```python nocheck donkey.last_call.observed: bool ``` @@ -381,7 +382,7 @@ True iff a governed response actually populated this record. ### `donkey.last_call.substituted` -```python +```python nocheck donkey.last_call.substituted: bool ``` @@ -389,7 +390,7 @@ True iff the gateway served a *different* model than the one requested — a sil ### `donkey.last_call.cache_hit` -```python +```python nocheck donkey.last_call.cache_hit: bool ``` @@ -397,7 +398,7 @@ True iff the gateway served this call from its semantic cache (`cache_status` == ### `donkey.last_call.available` -```python +```python nocheck donkey.last_call.available: bool ``` @@ -405,7 +406,7 @@ False only when the current surface structurally cannot be observed (the framewo ### `LastCall.from_response()` -```python +```python nocheck LastCall.from_response( response: httpx.Response, *, diff --git a/website/content/reference/configuration.mdx b/website/content/reference/configuration.mdx index 23d3775a..9743aa8a 100644 --- a/website/content/reference/configuration.mdx +++ b/website/content/reference/configuration.mdx @@ -1,5 +1,6 @@ --- description: Every DDK configuration value — LLM proxy URL and credentials, JWT model-wallet auth, attribution, cost tags, telemetry, and Anypoint control-plane settings — as env vars, config file, or code. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/reference/last-call.mdx b/website/content/reference/last-call.mdx index 55c5b406..79bb7eb1 100644 --- a/website/content/reference/last-call.mdx +++ b/website/content/reference/last-call.mdx @@ -1,5 +1,6 @@ --- description: Every field on donkey.last_call — the observability status, the gateway identity, the routing and fallback signals, the per-call token usage, and the semantic-cache outcome the gateway reported about the most recent governed model call. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/reference/unsupported-boundary.mdx b/website/content/reference/unsupported-boundary.mdx index dd3b2685..a5c60995 100644 --- a/website/content/reference/unsupported-boundary.mdx +++ b/website/content/reference/unsupported-boundary.mdx @@ -1,5 +1,6 @@ --- description: Which MuleSoft platform APIs DDK calls, whether each is documented for third-party use, and the project's support statement. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/release-notes.mdx b/website/content/release-notes.mdx index 08e578f7..099c1db6 100644 --- a/website/content/release-notes.mdx +++ b/website/content/release-notes.mdx @@ -1,5 +1,7 @@ --- description: Where the donkey-kit release notes live, what each one covers, and how to check which version you have installed and which one these docs describe. +status: live +statusBadge: false --- import { Callout } from 'nextra/components' diff --git a/website/content/roadmap.mdx b/website/content/roadmap.mdx index 7e9a4eef..5902a393 100644 --- a/website/content/roadmap.mdx +++ b/website/content/roadmap.mdx @@ -1,5 +1,7 @@ --- description: Where DDK is going — five phases in order, each with its goal, live progress and the issues in its milestone. +status: live +statusBadge: false --- import { Callout } from 'nextra/components' diff --git a/website/content/scenarios/index.mdx b/website/content/scenarios/index.mdx index ac742de3..4a82f696 100644 --- a/website/content/scenarios/index.mdx +++ b/website/content/scenarios/index.mdx @@ -1,5 +1,6 @@ --- description: Three end-to-end walkthroughs — support triage, an overnight batch job, and an internal copilot — each showing the governance layer a bare base_url cannot give you. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/scenarios/internal-copilot.mdx b/website/content/scenarios/internal-copilot.mdx index d5f115fd..45e3bcd5 100644 --- a/website/content/scenarios/internal-copilot.mdx +++ b/website/content/scenarios/internal-copilot.mdx @@ -1,5 +1,6 @@ --- description: An internal assistant whose output must clear a content-safety guardrail — the ContentSafetyBlocked branch driven by the simulator, with per-run correlation joining the refusal to your logs. +status: live --- import { Callout, Steps } from 'nextra/components' diff --git a/website/content/scenarios/nightly-batch.mdx b/website/content/scenarios/nightly-batch.mdx index d25d4a8d..219f3b99 100644 --- a/website/content/scenarios/nightly-batch.mdx +++ b/website/content/scenarios/nightly-batch.mdx @@ -1,5 +1,6 @@ --- description: Enrich tens of thousands of records overnight against a windowed token budget, unattended — pacing before the wall and resuming after the window resets, tested end to end against the simulator. +status: live --- import { Callout, Steps } from 'nextra/components' diff --git a/website/content/scenarios/support-triage.mdx b/website/content/scenarios/support-triage.mdx index 14fa406b..020fd1a6 100644 --- a/website/content/scenarios/support-triage.mdx +++ b/website/content/scenarios/support-triage.mdx @@ -1,5 +1,6 @@ --- description: Draft replies to a queue of support tickets, one carrying PII, with typed refusals, correlation IDs, and OTel spans — the shipped LangGraph demo, end to end, no gateway. +status: offline-verified --- import { Callout, Steps } from 'nextra/components' diff --git a/website/content/simulator.mdx b/website/content/simulator.mdx index eda26168..4f4eddd1 100644 --- a/website/content/simulator.mdx +++ b/website/content/simulator.mdx @@ -1,14 +1,12 @@ --- description: Run a local server that replays real gateway rejections, so you can build and test failure handling without a governed gateway. +status: live --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # Local simulator -Live - Nobody can make the production gateway emit a PII block on cue, so the branch of your agent that handles `PIIDetected` usually runs for the first time on a real customer's data. diff --git a/website/content/telemetry.mdx b/website/content/telemetry.mdx index 7ddef23a..85ded070 100644 --- a/website/content/telemetry.mdx +++ b/website/content/telemetry.mdx @@ -1,5 +1,6 @@ --- description: OpenTelemetry GenAI spans, per-run correlation IDs, and cost-attribution tags — in the dashboard you already run. +status: live --- import { Callout, Tabs } from 'nextra/components' @@ -7,8 +8,6 @@ import { Badge } from '../components' # Telemetry & cost -Live - DDK answers two questions about every governed call: *what happened?* and *who pays for it?* It does so with OpenTelemetry GenAI spans, per-run correlation IDs, cost-attribution tags, and routing and resilience signals. For what the diff --git a/website/content/testing.mdx b/website/content/testing.mdx index 44e71049..4e3a483e 100644 --- a/website/content/testing.mdx +++ b/website/content/testing.mdx @@ -1,14 +1,12 @@ --- description: Test your agent's failure handling — in-process refusal injection, a pytest conformance suite you run against your own agent, and an out-of-process gateway fixture. +status: live --- import { Callout } from 'nextra/components' -import { Badge } from '../components' # Testing & conformance -Live - Three test-time tools, all serving the same captured gateway rejections: - **`simulate()`** — make the next N calls through a `Donkey` return a chosen diff --git a/website/content/tool-access/a2a.mdx b/website/content/tool-access/a2a.mdx index 88fbbf6e..045ada6a 100644 --- a/website/content/tool-access/a2a.mdx +++ b/website/content/tool-access/a2a.mdx @@ -1,13 +1,11 @@ --- description: Bind a governed A2A agent from the registry as a native tool, so your agent can delegate to it without learning the A2A protocol. +status: roadmap --- -import { Badge } from '../../components' # A2A agent tools -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. Agent Broker is an A2A server, and A2A-compliant agents in the registry can be diff --git a/website/content/tool-access/binding.mdx b/website/content/tool-access/binding.mdx index 5ae8685a..21fba762 100644 --- a/website/content/tool-access/binding.mdx +++ b/website/content/tool-access/binding.mdx @@ -1,14 +1,12 @@ --- description: Turn discovered governed MCP servers into each framework's native tool objects, with auth, lifecycle, name collisions, and filtering handled for you. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../../components' # Framework binding -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. Once [discovery](/tool-access/discovery) hands you a `ToolSet`, binding turns diff --git a/website/content/tool-access/discovery.mdx b/website/content/tool-access/discovery.mdx index ca8a2e4b..a8357b3a 100644 --- a/website/content/tool-access/discovery.mdx +++ b/website/content/tool-access/discovery.mdx @@ -1,14 +1,12 @@ --- description: Find governed MCP tools by governance criteria, domain and tags, search the registry by name, asset type and environment, and see why any tool was included or excluded. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../../components' # Discovery, search & filter -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. `donkey.tools.discover(...)` is the one entry point for finding governed tools diff --git a/website/content/tool-access/index.mdx b/website/content/tool-access/index.mdx index 06b54213..5c747246 100644 --- a/website/content/tool-access/index.mdx +++ b/website/content/tool-access/index.mdx @@ -1,13 +1,12 @@ --- description: Discover the governed MCP tools your organisation publishes in Exchange and bind them into any framework as native tool objects. +status: roadmap --- -import { Badge, Card, CardGrid } from '../../components' +import { Card, CardGrid } from '../../components' # Tool access -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. Governed tool access lets an agent discover the MCP tools your organisation has diff --git a/website/content/tool-access/lockfile.mdx b/website/content/tool-access/lockfile.mdx index 1a150a05..f12dc529 100644 --- a/website/content/tool-access/lockfile.mdx +++ b/website/content/tool-access/lockfile.mdx @@ -1,14 +1,12 @@ --- description: Pin governed tool versions and content digests in a donkey.lock so catalog changes become reviewable diffs, not runtime surprises. +status: roadmap --- import { Callout } from 'nextra/components' -import { Badge } from '../../components' # Pinning & lockfile -Roadmap - This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. Governed tool catalogs change under you. A platform team edits an MCP server's diff --git a/website/content/troubleshooting.mdx b/website/content/troubleshooting.mdx index c7e47b0e..368e992e 100644 --- a/website/content/troubleshooting.mdx +++ b/website/content/troubleshooting.mdx @@ -1,5 +1,6 @@ --- description: How to read donkey doctor output, tell apart failures that look the same, explain an UNAVAILABLE donkey.last_call, and fix a 401 in JWT mode. +status: live --- import { Callout } from 'nextra/components' diff --git a/website/content/use-with-your-agent.mdx b/website/content/use-with-your-agent.mdx index f747682c..329a37f7 100644 --- a/website/content/use-with-your-agent.mdx +++ b/website/content/use-with-your-agent.mdx @@ -1,14 +1,12 @@ --- description: Point your coding assistant at these docs — llms.txt, llms-full.txt, and a .md next to every page — so it writes correct governed code. +status: live --- import { Callout, Tabs } from 'nextra/components' -import { Badge } from '../components' # Use these docs with your agent -Live - You probably ask your coding assistant before you open a docs site. These docs are published in the [llms.txt convention](https://llmstxt.org) so an assistant can read them directly and write **correct, governed** code — the base URL with diff --git a/website/lib/status.mjs b/website/lib/status.mjs new file mode 100644 index 00000000..a76d6409 --- /dev/null +++ b/website/lib/status.mjs @@ -0,0 +1,52 @@ +// The page-status vocabulary (#797). Every page under content/ declares one of +// these in its frontmatter (`status: live`), and that one value drives both the +// badge rendered at the top of the page (app/[[...mdxPath]]/page.tsx) and the +// status shown in llms.txt and the per-page .md files (scripts/generate-llms.mjs). +// Pages no longer hand-write a page-level . +// +// The values follow the verification ledger, docs/verified-apis.md (§0.3): +// live the API the page documents ships, and the gateway contract +// it rests on is VERIFIED (LIVE) or VERIFIED (CLI) there. +// offline-verified ships and is proven offline (conformance suite against the +// simulator, or verify_frameworks.py signature checks), but +// has had no real round-trip through a governed proxy yet; +// the ledger row still reads UNVERIFIED (signature confirmed). +// roadmap planned design: the API is not shipped, or it raises +// `_verify.blocked(...)` until its ledger row is confirmed. +// +// python/scripts/check_doc_snippets.py reads the same frontmatter: it skips +// `roadmap` pages and fails a `live` page that calls a blocked API. +/** @type {Record<'live' | 'offline-verified' | 'roadmap', {label: string, tone: 'live' | 'roadmap' | 'neutral', summary: string}>} */ +export const STATUSES = { + live: { + label: 'Live', + tone: 'live', + summary: 'Shipped, and verified against a real governed gateway.', + }, + 'offline-verified': { + label: 'Offline-verified', + tone: 'neutral', + summary: 'Shipped and verified offline; no live gateway round-trip yet.', + }, + roadmap: { + label: 'Roadmap', + tone: 'roadmap', + summary: 'Planned design; not shipped yet.', + }, +} + +// `statusBadge: false` in a page's frontmatter renders no badge, and the page's +// llms.txt entry and .md carry no status either. It is for pages that document +// no SDK surface: the landing page, the roadmap and the community pages. +/** + * @param {Record | undefined} frontmatter + * @param {string} [where] + */ +export function statusOf(frontmatter, where = 'page') { + const status = /** @type {keyof typeof STATUSES} */ (frontmatter?.status) + if (typeof status !== 'string' || !Object.hasOwn(STATUSES, status)) { + const allowed = Object.keys(STATUSES).join(', ') + throw new Error(`${where}: frontmatter status must be one of ${allowed}, got ${JSON.stringify(status)}`) + } + return { status, ...STATUSES[status], badge: String(frontmatter?.statusBadge) !== 'false' } +} diff --git a/website/lib/status.test.mjs b/website/lib/status.test.mjs new file mode 100644 index 00000000..2efec2b8 --- /dev/null +++ b/website/lib/status.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import { readdir, readFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { STATUSES, statusOf } from './status.mjs' + +const CONTENT = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'content') + +function frontmatter(src) { + const m = src.match(/^---\n([\s\S]*?)\n---\n/) + const out = {} + for (const line of m ? m[1].split('\n') : []) { + const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/) + if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '').trim() + } + return out +} + +async function pages() { + const entries = await readdir(CONTENT, { recursive: true }) + return entries.filter(e => e.endsWith('.mdx')).sort() +} + +test('the vocabulary is exactly live, offline-verified, roadmap', () => { + assert.deepEqual(Object.keys(STATUSES), ['live', 'offline-verified', 'roadmap']) +}) + +test('statusOf rejects a missing or unknown status', () => { + assert.throws(() => statusOf({}, 'x.mdx'), /x\.mdx: frontmatter status must be one of/) + assert.throws(() => statusOf({ status: 'beta' }), /got "beta"/) + assert.equal(statusOf({ status: 'roadmap' }).label, 'Roadmap') + assert.equal(statusOf({ status: 'live', statusBadge: 'false' }).badge, false) + assert.equal(statusOf({ status: 'live' }).badge, true) +}) + +test('every page declares a valid status in its frontmatter', async () => { + for (const page of await pages()) { + const fm = frontmatter(await readFile(join(CONTENT, page), 'utf8')) + assert.doesNotThrow(() => statusOf(fm, page), page) + } +}) + +// The page-level badge comes from frontmatter, so a hand-written one would +// show twice (or contradict the status). Inline badges in tables, cards and +// legends further down are fine; this checks the lines right under the H1. +test('no page hand-writes its page-level status badge', async () => { + for (const page of await pages()) { + const lines = (await readFile(join(CONTENT, page), 'utf8')).split('\n') + const h1 = lines.findIndex(l => l.startsWith('# ')) + if (h1 < 0) continue + const next = lines.slice(h1 + 1).find(l => l.trim() !== '') ?? '' + assert.doesNotMatch(next, /^} */ -export async function documentedVersion(path = PYPROJECT) { +export async function documentedVersion(path = PACKAGE_INIT) { try { - return projectVersion(await readFile(path, 'utf8')) + return packageVersion(await readFile(path, 'utf8')) } catch { return null } diff --git a/website/lib/version.test.mjs b/website/lib/version.test.mjs index 90d44e53..42612efa 100644 --- a/website/lib/version.test.mjs +++ b/website/lib/version.test.mjs @@ -8,37 +8,32 @@ import { PYPI_JSON_URL, documentedVersion, latestPypiVersion, - projectVersion, + packageVersion, versionBanner, } from './version.mjs' const PYTHON = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'python') test('the documented version is the package version', async () => { - const documented = await documentedVersion(resolve(PYTHON, 'pyproject.toml')) - const init = await readFile(resolve(PYTHON, 'src', 'donkey_kit', '__init__.py'), 'utf8') - const match = init.match(/^__version__\s*=\s*"([^"]+)"/m) - assert.ok(documented, 'pyproject.toml has no [project] version') - assert.ok(match, '__init__.py has no __version__') - assert.equal(documented, match[1]) + const documented = await documentedVersion(resolve(PYTHON, 'src', 'donkey_kit', '__init__.py')) + const pyproject = await readFile(resolve(PYTHON, 'pyproject.toml'), 'utf8') + assert.ok(documented, '__init__.py has no __version__') + // #767: the version is declared once, in __init__.py; pyproject.toml reads it. + assert.match(pyproject, /^dynamic = \["version"\]$/m) }) -test('only the [project] table version is read', () => { +test('only a module-level __version__ assignment is read', () => { const text = [ - '[build-system]', - 'version = "9.9.9"', - '[project]', - 'name = "donkey-kit"', - 'version = "0.1.2"', - '[tool.other]', - 'version = "8.8.8"', + '"""donkey-kit."""', + ' __version__ = "9.9.9"', + '__version__ = "0.1.2"', ].join('\n') - assert.equal(projectVersion(text), '0.1.2') - assert.equal(projectVersion('[tool.x]\nversion = "1"\n'), null) + assert.equal(packageVersion(text), '0.1.2') + assert.equal(packageVersion('VERSION = "1"\n'), null) }) -test('a missing pyproject.toml means no documented version', async () => { - assert.equal(await documentedVersion('/nonexistent/pyproject.toml'), null) +test('a missing __init__.py means no documented version', async () => { + assert.equal(await documentedVersion('/nonexistent/__init__.py'), null) }) test('the latest PyPI version comes from info.version', async () => { diff --git a/website/package.json b/website/package.json index 6b37729d..269e0c4d 100644 --- a/website/package.json +++ b/website/package.json @@ -11,7 +11,7 @@ "build": "next build", "postbuild": "pagefind --site out --output-subdir _pagefind", "preview:pages": "node scripts/preview-pages.mjs", - "test": "node --test lib/analytics.test.mjs lib/repo.test.mjs lib/site.test.mjs lib/version.test.mjs", + "test": "node --test lib/analytics.test.mjs lib/repo.test.mjs lib/site.test.mjs lib/status.test.mjs lib/version.test.mjs", "start": "next start -p ${PORT:-3000}" }, "dependencies": { diff --git a/website/public/a2a.md b/website/public/a2a.md index 6e281fa6..993b1466 100644 --- a/website/public/a2a.md +++ b/website/public/a2a.md @@ -1,6 +1,6 @@ # A2A agents -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/budget.md b/website/public/budget.md index be1c3790..d14656ee 100644 --- a/website/public/budget.md +++ b/website/public/budget.md @@ -1,6 +1,6 @@ # Budget & pacing -Live +Status: Live. Shipped, and verified against a real governed gateway. Once a token-rate-limit policy is applied, the governed proxy reports your token budget on its responses, in **two shapes** depending on the response: diff --git a/website/public/cli.md b/website/public/cli.md index 16637f9f..ae32ccd6 100644 --- a/website/public/cli.md +++ b/website/public/cli.md @@ -1,6 +1,6 @@ # CLI & decorators -Live +Status: Live. Shipped, and verified against a real governed gateway. Two on-ramps to the SDK: decorators that govern a function in one line, and a four-command CLI for setup, diagnosis, local simulation, and conformance diff --git a/website/public/errors.md b/website/public/errors.md index a6e02456..42389a8f 100644 --- a/website/public/errors.md +++ b/website/public/errors.md @@ -1,6 +1,6 @@ # Governed error taxonomy -Live +Status: Live. Shipped, and verified against a real governed gateway. The proxy doesn't just pass model calls through — it enforces policy. When it rejects a call, DDK turns the response into a **typed exception** so you diff --git a/website/public/examples.md b/website/public/examples.md index 3bce7336..f3feee75 100644 --- a/website/public/examples.md +++ b/website/public/examples.md @@ -1,5 +1,7 @@ # Examples +Status: Live. Shipped, and verified against a real governed gateway. + The [DDK demos repo](https://github.com/Donkey-Development-Kit/donkey-development-kit-demos) holds runnable examples for every piece of the SDK: the governed client, typed refusals, budget pacing, simulation, the conformance suite, telemetry, diff --git a/website/public/examples/adk.md b/website/public/examples/adk.md index 9c1da02a..ee8dedb4 100644 --- a/website/public/examples/adk.md +++ b/website/public/examples/adk.md @@ -1,5 +1,7 @@ # Google ADK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Google ADK with `donkey.adk.model("…")`, a `LiteLlm` model. LiteLLM calls the proxy's `/chat/completions` route. It sends through the SDK's shared HTTP client, so the credentials and the run id go on the wire. LiteLLM raises its own errors, so a refusal becomes a **typed diff --git a/website/public/examples/agent-framework.md b/website/public/examples/agent-framework.md index c8806ce4..81fa616b 100644 --- a/website/public/examples/agent-framework.md +++ b/website/public/examples/agent-framework.md @@ -1,5 +1,7 @@ # Microsoft Agent Framework +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + `donkey.agent_framework.chat_client("…")` builds an `OpenAIChatCompletionClient` (verified against 1.19.0, where the keyword is `model=`) that calls `/chat/completions`. It sends through the SDK's shared HTTP client, so the run id diff --git a/website/public/examples/anthropic.md b/website/public/examples/anthropic.md index b21b0ad9..29133d74 100644 --- a/website/public/examples/anthropic.md +++ b/website/public/examples/anthropic.md @@ -1,5 +1,7 @@ # Anthropic +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The native `anthropic` client from `donkey.anthropic.client()`, on the governed transport. It sends `/v1/messages`, so it needs a proxy provisioned **`Format=Anthropic`** (for example `ddk-anthropic-inbound`). The default DDK diff --git a/website/public/examples/crewai.md b/website/public/examples/crewai.md index 3d56c790..13da02bd 100644 --- a/website/public/examples/crewai.md +++ b/website/public/examples/crewai.md @@ -1,5 +1,7 @@ # CrewAI +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + CrewAI 1.x with `donkey.crewai.llm("…")`. `openai/` models go through CrewAI's native OpenAI provider to the proxy's `/chat/completions` route. CrewAI owns the transport: the credentials go on the wire, but there is **no run id and no `last_call`**. diff --git a/website/public/examples/gemini.md b/website/public/examples/gemini.md index 47c72f5f..ee6b193e 100644 --- a/website/public/examples/gemini.md +++ b/website/public/examples/gemini.md @@ -1,5 +1,7 @@ # Gemini +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + These scripts show the wire: plain `httpx` against a proxy provisioned **`Format=Gemini`** (for example `ddk-gemini-inbound`) with the same `client_id` / `client_secret` pair. For an agent, ADK's native `Gemini` model is diff --git a/website/public/examples/general/budget-and-pacing.md b/website/public/examples/general/budget-and-pacing.md index cc11e267..eddd93be 100644 --- a/website/public/examples/general/budget-and-pacing.md +++ b/website/public/examples/general/budget-and-pacing.md @@ -1,5 +1,7 @@ # Budget & pacing +Status: Live. Shipped, and verified against a real governed gateway. + The gateway reports your token budget only in-band, on response headers — there is no endpoint to ask how much is left. `donkey.budget` reads those headers for you, so the window is an object rather than a header you parse. diff --git a/website/public/examples/general/conformance.md b/website/public/examples/general/conformance.md index 25fdf3c0..90372db4 100644 --- a/website/public/examples/general/conformance.md +++ b/website/public/examples/general/conformance.md @@ -1,5 +1,7 @@ # Conformance suite +Status: Live. Shipped, and verified against a real governed gateway. + Four questions a team usually cannot answer about its own agent: does it retry a budget refusal (it must not)? Does a typed refusal survive its error handling? Does the run's correlation id reach its logs? Does it still work when diff --git a/website/public/examples/general/framework-objects.md b/website/public/examples/general/framework-objects.md index 7ab7e229..4ae70878 100644 --- a/website/public/examples/general/framework-objects.md +++ b/website/public/examples/general/framework-objects.md @@ -1,5 +1,7 @@ # Framework objects & model handles +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + No adapter returns a wrapper. `donkey.langgraph.chat_model(...)` hands back a real `langchain_openai.ChatOpenAI`, so everything your framework can do with a model still works and nothing new appears in your stack traces. LangGraph is diff --git a/website/public/examples/general/gateway-identity.md b/website/public/examples/general/gateway-identity.md index 63e083b9..f4bddf85 100644 --- a/website/public/examples/general/gateway-identity.md +++ b/website/public/examples/general/gateway-identity.md @@ -1,5 +1,7 @@ # Gateway identity (last_call) +Status: Live. Shipped, and verified against a real governed gateway. + A refusal already tells you which gateway said no. `donkey.last_call` is the success-path counterpart: after every governed call it records which gateway served the request, what it actually routed to, and what the call cost — diff --git a/website/public/examples/general/governed-client.md b/website/public/examples/general/governed-client.md index d403e3ab..b103421e 100644 --- a/website/public/examples/general/governed-client.md +++ b/website/public/examples/general/governed-client.md @@ -1,5 +1,7 @@ # Governed client +Status: Live. Shipped, and verified against a real governed gateway. + You do not need the SDK to reach the gateway — a stock OpenAI client with a `base_url` and two headers gets there. These examples show that first, then show what the stock client leaves you holding. Both clients hit the same diff --git a/website/public/examples/general/langgraph-agent.md b/website/public/examples/general/langgraph-agent.md index e79c01dd..858be9d6 100644 --- a/website/public/examples/general/langgraph-agent.md +++ b/website/public/examples/general/langgraph-agent.md @@ -1,5 +1,7 @@ # LangGraph agent +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + A real multi-step agent: the model decides to call two tools, the tools return, and the model composes an answer. Every model call in that loop goes through the governed proxy, and the object driving it is LangChain's own diff --git a/website/public/examples/general/simulating-refusals.md b/website/public/examples/general/simulating-refusals.md index 12d7d7b8..5a3061e2 100644 --- a/website/public/examples/general/simulating-refusals.md +++ b/website/public/examples/general/simulating-refusals.md @@ -1,5 +1,7 @@ # Simulating refusals +Status: Live. Shipped, and verified against a real governed gateway. + Every agent has an `except PIIDetected:` branch that has never executed. Getting a real gateway to refuse on demand means finding a prompt that trips a live policy — slow, flaky, and not something you can put in CI. diff --git a/website/public/examples/general/telemetry.md b/website/public/examples/general/telemetry.md index 8f9f5ed7..9604a811 100644 --- a/website/public/examples/general/telemetry.md +++ b/website/public/examples/general/telemetry.md @@ -1,5 +1,7 @@ # Telemetry +Status: Live. Shipped, and verified against a real governed gateway. + Platform teams ask for two things agent teams rarely deliver: a trace that follows one logical run across every model call it fans out into, and spans in the standard GenAI vocabulary so they land in existing dashboards. Both come diff --git a/website/public/examples/general/typed-refusals.md b/website/public/examples/general/typed-refusals.md index 25942641..571eb7f5 100644 --- a/website/public/examples/general/typed-refusals.md +++ b/website/public/examples/general/typed-refusals.md @@ -1,5 +1,7 @@ # Typed refusals +Status: Live. Shipped, and verified against a real governed gateway. + Governance outcomes should be something you branch on, not something you parse. These examples run the gateway's rejection shapes through `classify()` and show the exception hierarchy you write `except` clauses against. The diff --git a/website/public/examples/langgraph.md b/website/public/examples/langgraph.md index 3dcda6f1..4db354ea 100644 --- a/website/public/examples/langgraph.md +++ b/website/public/examples/langgraph.md @@ -1,5 +1,7 @@ # LangGraph +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + `donkey.langgraph("…")` returns a real `langchain_openai.ChatOpenAI` with `use_responses_api=False`, so calls go to `/chat/completions`, the route every upstream serves, on the governed transport. It is the one deep, conformance-gated adapter: diff --git a/website/public/examples/llamaindex.md b/website/public/examples/llamaindex.md index d13441cb..96d94985 100644 --- a/website/public/examples/llamaindex.md +++ b/website/public/examples/llamaindex.md @@ -1,5 +1,7 @@ # LlamaIndex +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + LlamaIndex with `donkey.llamaindex.llm("…")`, an `OpenAILike`. The adapter sets `is_chat_model=True` — the `OpenAILike` default of `False` hits `/completions` — so calls go to the proxy's `/chat/completions` route. Sync diff --git a/website/public/examples/openai-agents.md b/website/public/examples/openai-agents.md index 0ae33577..0337a2d2 100644 --- a/website/public/examples/openai-agents.md +++ b/website/public/examples/openai-agents.md @@ -1,5 +1,7 @@ # OpenAI Agents SDK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The agent is given `OpenAIResponsesModel(model=…, **donkey.openai_agents.connection_kwargs())`. `connection_kwargs()` is one key, `openai_client`, a governed `AsyncOpenAI`, so the agent stays on the diff --git a/website/public/examples/openai.md b/website/public/examples/openai.md index 3f335543..984ca618 100644 --- a/website/public/examples/openai.md +++ b/website/public/examples/openai.md @@ -1,5 +1,7 @@ # OpenAI +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + `donkey.openai()` returns the stock `openai.AsyncOpenAI` (or `OpenAI` with `sync=True`) on the SDK's governed transport. Because the SDK owns every request, everything works here: `last_call`, the budget, spans, correlation diff --git a/website/public/examples/strands.md b/website/public/examples/strands.md index 76311734..d60875a0 100644 --- a/website/public/examples/strands.md +++ b/website/public/examples/strands.md @@ -1,5 +1,7 @@ # Strands +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Strands Agents with `OpenAIModel(client=donkey.openai(), model_id=…)`. Because the governed client is passed in, the SDK owns the transport: run id, `last_call` and typed refusals all work. The calls go to the proxy's diff --git a/website/public/feature-overview.md b/website/public/feature-overview.md index 180fca4a..aa8cd317 100644 --- a/website/public/feature-overview.md +++ b/website/public/feature-overview.md @@ -1,5 +1,7 @@ # Feature overview +Status: Live. Shipped, and verified against a real governed gateway. + DDK is **gateway-aware**: control stays at the proxy, efficiency moves into the agent. The gateway enforces policy; DDK makes each decision visible and actionable in your code, so the agent reacts to a refusal, paces its budget and diff --git a/website/public/frameworks.md b/website/public/frameworks.md index ad07ff20..633bd949 100644 --- a/website/public/frameworks.md +++ b/website/public/frameworks.md @@ -1,5 +1,7 @@ # Model access +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Governed model access from eight agent frameworks. Each adapter returns the framework's **own native object**, pointed at your Omni Gateway LLM proxy with consumer auth and attribution headers already set. Nothing wraps the object you @@ -69,7 +71,8 @@ export DONKEY_LLM_PROXY_URL=… DONKEY_LLM_PROXY_CLIENT_ID=… DONKEY_LLM_PROX ```python from donkey_kit import Donkey async with Donkey.from_env() as donkey: - model = donkey..("gpt-4o") # native object at the proxy + # donkey..(...), here LangGraph's chat_model: + model = donkey.langgraph.chat_model("gpt-4o") # native object at the proxy ``` Each framework page shows the factory name, the native class you get back, the diff --git a/website/public/frameworks/adk.md b/website/public/frameworks/adk.md index 7574c91b..317952ad 100644 --- a/website/public/frameworks/adk.md +++ b/website/public/frameworks/adk.md @@ -1,5 +1,7 @@ # Google ADK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Google's Agent Development Kit (ADK) reaches the Agent Fabric LLM proxy in one of two ways, depending on the proxy's ingress **Format**: diff --git a/website/public/frameworks/agent-framework.md b/website/public/frameworks/agent-framework.md index 6f6bc2bc..97f0acf3 100644 --- a/website/public/frameworks/agent-framework.md +++ b/website/public/frameworks/agent-framework.md @@ -1,5 +1,7 @@ # Microsoft Agent Framework +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Microsoft Agent Framework gets a governed chat client pointed at the Omni Gateway LLM proxy, plus policy middleware that ends a run on a governance rejection with the SDK's typed refusal. diff --git a/website/public/frameworks/anthropic.md b/website/public/frameworks/anthropic.md index d2e61d30..813b46a6 100644 --- a/website/public/frameworks/anthropic.md +++ b/website/public/frameworks/anthropic.md @@ -1,5 +1,7 @@ # Anthropic SDK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The Anthropic SDK gets a governed `AsyncAnthropic` client pointed at the Omni Gateway LLM proxy, with the SDK's shared transport and proxy headers passed straight into the client constructor. diff --git a/website/public/frameworks/crewai.md b/website/public/frameworks/crewai.md index 6c97e0a8..0476c9be 100644 --- a/website/public/frameworks/crewai.md +++ b/website/public/frameworks/crewai.md @@ -1,5 +1,7 @@ # CrewAI +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + CrewAI gets a governed native LLM object pointed at the Agent Fabric LLM proxy. The adapter translates the governed connection into CrewAI's own model-string and kwarg conventions for you. diff --git a/website/public/frameworks/langgraph.md b/website/public/frameworks/langgraph.md index c26d47b6..af990656 100644 --- a/website/public/frameworks/langgraph.md +++ b/website/public/frameworks/langgraph.md @@ -1,5 +1,7 @@ # LangGraph +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + LangGraph (and LangChain more broadly) gets a governed `ChatOpenAI` pointed at your Agent Fabric LLM proxy. LangGraph is the **deep adapter**: every proxy header and the SDK's shared async transport reach the native client, and the diff --git a/website/public/frameworks/llamaindex.md b/website/public/frameworks/llamaindex.md index 4e04c5df..94eb71b8 100644 --- a/website/public/frameworks/llamaindex.md +++ b/website/public/frameworks/llamaindex.md @@ -1,5 +1,7 @@ # LlamaIndex +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + LlamaIndex gets a governed `OpenAILike` LLM pointed at the Agent Fabric LLM proxy, with the chat-model flag a chat-only gateway requires already set. diff --git a/website/public/frameworks/openai.md b/website/public/frameworks/openai.md index 0590ccb2..c029e114 100644 --- a/website/public/frameworks/openai.md +++ b/website/public/frameworks/openai.md @@ -1,5 +1,7 @@ # OpenAI Agents SDK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The OpenAI Agents SDK (pip package `openai-agents`) gets a governed `OpenAIChatCompletionsModel` backed by a pre-built `AsyncOpenAI` client. The adapter builds that client itself, with the SDK's shared HTTP client and proxy diff --git a/website/public/frameworks/strands.md b/website/public/frameworks/strands.md index 51276dd2..eab04e94 100644 --- a/website/public/frameworks/strands.md +++ b/website/public/frameworks/strands.md @@ -1,5 +1,7 @@ # Strands Agents +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Strands Agents gets a governed `OpenAIModel` pointed at the Agent Fabric LLM proxy. The connection details travel in Strands' `client_args`, which Strands passes straight through to its underlying OpenAI client. diff --git a/website/public/hitl.md b/website/public/hitl.md index e07f6dd6..f49e9d48 100644 --- a/website/public/hitl.md +++ b/website/public/hitl.md @@ -1,6 +1,6 @@ # Human-in-the-loop -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/identity.md b/website/public/identity.md index 0ab95a2a..e94c819e 100644 --- a/website/public/identity.md +++ b/website/public/identity.md @@ -1,6 +1,6 @@ # Identity -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index a6a12f7a..909cffc9 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -270,6 +270,8 @@ Source: https://docs.donkey-kit.dev/quickstart.md # Quickstart +Status: Live. Shipped, and verified against a real governed gateway. + Make your first governed model call, then see what a plain `base_url` can't give you — a **typed refusal**, a **budget** and a **span** — in a few minutes, on your laptop. You start against DDK's **local gateway simulator**, @@ -571,6 +573,8 @@ Source: https://docs.donkey-kit.dev/feature-overview.md # Feature overview +Status: Live. Shipped, and verified against a real governed gateway. + DDK is **gateway-aware**: control stays at the proxy, efficiency moves into the agent. The gateway enforces policy; DDK makes each decision visible and actionable in your code, so the agent reacts to a refusal, paces its budget and @@ -722,6 +726,8 @@ Source: https://docs.donkey-kit.dev/frameworks.md # Model access +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Governed model access from eight agent frameworks. Each adapter returns the framework's **own native object**, pointed at your Omni Gateway LLM proxy with consumer auth and attribution headers already set. Nothing wraps the object you @@ -791,7 +797,8 @@ export DONKEY_LLM_PROXY_URL=… DONKEY_LLM_PROXY_CLIENT_ID=… DONKEY_LLM_PROX ```python from donkey_kit import Donkey async with Donkey.from_env() as donkey: - model = donkey..("gpt-4o") # native object at the proxy + # donkey..(...), here LangGraph's chat_model: + model = donkey.langgraph.chat_model("gpt-4o") # native object at the proxy ``` Each framework page shows the factory name, the native class you get back, the @@ -972,6 +979,8 @@ Source: https://docs.donkey-kit.dev/frameworks/langgraph.md # LangGraph +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + LangGraph (and LangChain more broadly) gets a governed `ChatOpenAI` pointed at your Agent Fabric LLM proxy. LangGraph is the **deep adapter**: every proxy header and the SDK's shared async transport reach the native client, and the @@ -1269,6 +1278,8 @@ Source: https://docs.donkey-kit.dev/frameworks/adk.md # Google ADK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Google's Agent Development Kit (ADK) reaches the Agent Fabric LLM proxy in one of two ways, depending on the proxy's ingress **Format**: @@ -1551,6 +1562,8 @@ Source: https://docs.donkey-kit.dev/frameworks/strands.md # Strands Agents +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Strands Agents gets a governed `OpenAIModel` pointed at the Agent Fabric LLM proxy. The connection details travel in Strands' `client_args`, which Strands passes straight through to its underlying OpenAI client. @@ -1700,6 +1713,8 @@ Source: https://docs.donkey-kit.dev/frameworks/agent-framework.md # Microsoft Agent Framework +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Microsoft Agent Framework gets a governed chat client pointed at the Omni Gateway LLM proxy, plus policy middleware that ends a run on a governance rejection with the SDK's typed refusal. @@ -1900,6 +1915,8 @@ Source: https://docs.donkey-kit.dev/frameworks/openai.md # OpenAI Agents SDK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The OpenAI Agents SDK (pip package `openai-agents`) gets a governed `OpenAIChatCompletionsModel` backed by a pre-built `AsyncOpenAI` client. The adapter builds that client itself, with the SDK's shared HTTP client and proxy @@ -2049,6 +2066,8 @@ Source: https://docs.donkey-kit.dev/frameworks/anthropic.md # Anthropic SDK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The Anthropic SDK gets a governed `AsyncAnthropic` client pointed at the Omni Gateway LLM proxy, with the SDK's shared transport and proxy headers passed straight into the client constructor. @@ -2221,6 +2240,8 @@ Source: https://docs.donkey-kit.dev/frameworks/crewai.md # CrewAI +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + CrewAI gets a governed native LLM object pointed at the Agent Fabric LLM proxy. The adapter translates the governed connection into CrewAI's own model-string and kwarg conventions for you. @@ -2371,6 +2392,8 @@ Source: https://docs.donkey-kit.dev/frameworks/llamaindex.md # LlamaIndex +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + LlamaIndex gets a governed `OpenAILike` LLM pointed at the Agent Fabric LLM proxy, with the chat-model flag a chat-only gateway requires already set. @@ -2546,7 +2569,7 @@ Source: https://docs.donkey-kit.dev/errors.md # Governed error taxonomy -Live +Status: Live. Shipped, and verified against a real governed gateway. The proxy doesn't just pass model calls through — it enforces policy. When it rejects a call, DDK turns the response into a **typed exception** so you @@ -2995,7 +3018,7 @@ Source: https://docs.donkey-kit.dev/budget.md # Budget & pacing -Live +Status: Live. Shipped, and verified against a real governed gateway. Once a token-rate-limit policy is applied, the governed proxy reports your token budget on its responses, in **two shapes** depending on the response: @@ -3200,7 +3223,7 @@ Source: https://docs.donkey-kit.dev/identity.md # Identity -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -3252,7 +3275,7 @@ Source: https://docs.donkey-kit.dev/hitl.md # Human-in-the-loop -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -3316,7 +3339,7 @@ Source: https://docs.donkey-kit.dev/policies.md # Policy handshake -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. It depends on a policy-discovery endpoint on the gateway, which does not @@ -3370,7 +3393,7 @@ Source: https://docs.donkey-kit.dev/telemetry.md # Telemetry & cost -Live +Status: Live. Shipped, and verified against a real governed gateway. DDK answers two questions about every governed call: *what happened?* and *who pays for it?* It does so with OpenTelemetry GenAI spans, per-run correlation @@ -4035,7 +4058,7 @@ Source: https://docs.donkey-kit.dev/simulator.md # Local simulator -Live +Status: Live. Shipped, and verified against a real governed gateway. Nobody can make the production gateway emit a PII block on cue, so the branch of your agent that handles `PIIDetected` usually runs for the first time on a @@ -4296,7 +4319,7 @@ Source: https://docs.donkey-kit.dev/testing.md # Testing & conformance -Live +Status: Live. Shipped, and verified against a real governed gateway. Three test-time tools, all serving the same captured gateway rejections: @@ -4456,7 +4479,7 @@ Source: https://docs.donkey-kit.dev/cli.md # CLI & decorators -Live +Status: Live. Shipped, and verified against a real governed gateway. Two on-ramps to the SDK: decorators that govern a function in one line, and a four-command CLI for setup, diagnosis, local simulation, and conformance @@ -4742,6 +4765,8 @@ Source: https://docs.donkey-kit.dev/troubleshooting.md # Troubleshooting +Status: Live. Shipped, and verified against a real governed gateway. + Start with `donkey doctor`. It makes one real governed call and tells apart the failures that look the same from your code. The sections below explain each line it prints, then cover the problems `doctor` can't see. @@ -4883,7 +4908,7 @@ Source: https://docs.donkey-kit.dev/use-with-your-agent.md # Use these docs with your agent -Live +Status: Live. Shipped, and verified against a real governed gateway. You probably ask your coding assistant before you open a docs site. These docs are published in the [llms.txt convention](https://llmstxt.org) so an assistant @@ -4953,7 +4978,7 @@ Source: https://docs.donkey-kit.dev/tool-access.md # Tool access -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -5013,7 +5038,7 @@ Source: https://docs.donkey-kit.dev/tool-access/discovery.md # Discovery, search & filter -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -5157,7 +5182,7 @@ Source: https://docs.donkey-kit.dev/tool-access/binding.md # Framework binding -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -5246,7 +5271,7 @@ Source: https://docs.donkey-kit.dev/tool-access/lockfile.md # Pinning & lockfile -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -5330,7 +5355,7 @@ Source: https://docs.donkey-kit.dev/tool-access/a2a.md # A2A agent tools -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -5371,7 +5396,7 @@ Source: https://docs.donkey-kit.dev/a2a.md # A2A agents -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -5453,7 +5478,7 @@ Source: https://docs.donkey-kit.dev/publishing.md # Scan & publish -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. @@ -5602,6 +5627,8 @@ Source: https://docs.donkey-kit.dev/scenarios.md # Scenarios +Status: Live. Shipped, and verified against a real governed gateway. + The feature pages tell you what each piece *is*. These pages show three of them working together on a real job, start to finish — the same three scenarios the build guide uses to justify the skeleton (`BG §1.8`). Each one is runnable @@ -5638,6 +5665,8 @@ Source: https://docs.donkey-kit.dev/scenarios/support-triage.md # Support triage +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + A support agent drafts a one-line reply to each ticket in a queue. Most are routine; one contains a customer's SSN. On a bare `base_url` that PII-laden call sails straight through to the model. Through a governed proxy it comes back as a @@ -5753,6 +5782,8 @@ Source: https://docs.donkey-kit.dev/scenarios/nightly-batch.md # Nightly batch +Status: Live. Shipped, and verified against a real governed gateway. + 50,000 product records, enriched overnight against a governed model, budget window resetting every hour, no human awake. Without a budget object the script runs flat out, takes a `429` partway through, crashes, and someone re-runs it @@ -5880,6 +5911,8 @@ Source: https://docs.donkey-kit.dev/scenarios/internal-copilot.md # Internal copilot +Status: Live. Shipped, and verified against a real governed gateway. + An internal copilot answers employee questions against company systems. Its output has to clear a **content-safety guardrail** before it reaches a person, and when the guardrail fires you need the refusal to (a) surface as something @@ -5998,6 +6031,8 @@ Source: https://docs.donkey-kit.dev/examples.md # Examples +Status: Live. Shipped, and verified against a real governed gateway. + The [DDK demos repo](https://github.com/Donkey-Development-Kit/donkey-development-kit-demos) holds runnable examples for every piece of the SDK: the governed client, typed refusals, budget pacing, simulation, the conformance suite, telemetry, @@ -6230,6 +6265,8 @@ Source: https://docs.donkey-kit.dev/examples/general/governed-client.md # Governed client +Status: Live. Shipped, and verified against a real governed gateway. + You do not need the SDK to reach the gateway — a stock OpenAI client with a `base_url` and two headers gets there. These examples show that first, then show what the stock client leaves you holding. Both clients hit the same @@ -6475,6 +6512,8 @@ Source: https://docs.donkey-kit.dev/examples/general/typed-refusals.md # Typed refusals +Status: Live. Shipped, and verified against a real governed gateway. + Governance outcomes should be something you branch on, not something you parse. These examples run the gateway's rejection shapes through `classify()` and show the exception hierarchy you write `except` clauses against. The @@ -6810,6 +6849,8 @@ Source: https://docs.donkey-kit.dev/examples/general/budget-and-pacing.md # Budget & pacing +Status: Live. Shipped, and verified against a real governed gateway. + The gateway reports your token budget only in-band, on response headers — there is no endpoint to ask how much is left. `donkey.budget` reads those headers for you, so the window is an object rather than a header you parse. @@ -6998,6 +7039,8 @@ Source: https://docs.donkey-kit.dev/examples/general/simulating-refusals.md # Simulating refusals +Status: Live. Shipped, and verified against a real governed gateway. + Every agent has an `except PIIDetected:` branch that has never executed. Getting a real gateway to refuse on demand means finding a prompt that trips a live policy — slow, flaky, and not something you can put in CI. @@ -7204,6 +7247,8 @@ Source: https://docs.donkey-kit.dev/examples/general/conformance.md # Conformance suite +Status: Live. Shipped, and verified against a real governed gateway. + Four questions a team usually cannot answer about its own agent: does it retry a budget refusal (it must not)? Does a typed refusal survive its error handling? Does the run's correlation id reach its logs? Does it still work when @@ -7433,6 +7478,8 @@ Source: https://docs.donkey-kit.dev/examples/general/telemetry.md # Telemetry +Status: Live. Shipped, and verified against a real governed gateway. + Platform teams ask for two things agent teams rarely deliver: a trace that follows one logical run across every model call it fans out into, and spans in the standard GenAI vocabulary so they land in existing dashboards. Both come @@ -7712,6 +7759,8 @@ Source: https://docs.donkey-kit.dev/examples/general/framework-objects.md # Framework objects & model handles +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + No adapter returns a wrapper. `donkey.langgraph.chat_model(...)` hands back a real `langchain_openai.ChatOpenAI`, so everything your framework can do with a model still works and nothing new appears in your stack traces. LangGraph is @@ -7962,6 +8011,8 @@ Source: https://docs.donkey-kit.dev/examples/general/langgraph-agent.md # LangGraph agent +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + A real multi-step agent: the model decides to call two tools, the tools return, and the model composes an answer. Every model call in that loop goes through the governed proxy, and the object driving it is LangChain's own @@ -8054,6 +8105,8 @@ Source: https://docs.donkey-kit.dev/examples/general/gateway-identity.md # Gateway identity (last_call) +Status: Live. Shipped, and verified against a real governed gateway. + A refusal already tells you which gateway said no. `donkey.last_call` is the success-path counterpart: after every governed call it records which gateway served the request, what it actually routed to, and what the call cost — @@ -8318,6 +8371,8 @@ Source: https://docs.donkey-kit.dev/examples/openai.md # OpenAI +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + `donkey.openai()` returns the stock `openai.AsyncOpenAI` (or `OpenAI` with `sync=True`) on the SDK's governed transport. Because the SDK owns every request, everything works here: `last_call`, the budget, spans, correlation @@ -8816,6 +8871,8 @@ Source: https://docs.donkey-kit.dev/examples/langgraph.md # LangGraph +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + `donkey.langgraph("…")` returns a real `langchain_openai.ChatOpenAI` with `use_responses_api=False`, so calls go to `/chat/completions`, the route every upstream serves, on the governed transport. It is the one deep, conformance-gated adapter: @@ -9063,6 +9120,8 @@ Source: https://docs.donkey-kit.dev/examples/openai-agents.md # OpenAI Agents SDK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The agent is given `OpenAIResponsesModel(model=…, **donkey.openai_agents.connection_kwargs())`. `connection_kwargs()` is one key, `openai_client`, a governed `AsyncOpenAI`, so the agent stays on the @@ -9205,6 +9264,8 @@ Source: https://docs.donkey-kit.dev/examples/agent-framework.md # Microsoft Agent Framework +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + `donkey.agent_framework.chat_client("…")` builds an `OpenAIChatCompletionClient` (verified against 1.19.0, where the keyword is `model=`) that calls `/chat/completions`. It sends through the SDK's shared HTTP client, so the run id @@ -9321,6 +9382,8 @@ Source: https://docs.donkey-kit.dev/examples/strands.md # Strands +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Strands Agents with `OpenAIModel(client=donkey.openai(), model_id=…)`. Because the governed client is passed in, the SDK owns the transport: run id, `last_call` and typed refusals all work. The calls go to the proxy's @@ -9455,6 +9518,8 @@ Source: https://docs.donkey-kit.dev/examples/crewai.md # CrewAI +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + CrewAI 1.x with `donkey.crewai.llm("…")`. `openai/` models go through CrewAI's native OpenAI provider to the proxy's `/chat/completions` route. CrewAI owns the transport: the credentials go on the wire, but there is **no run id and no `last_call`**. @@ -9543,6 +9608,8 @@ Source: https://docs.donkey-kit.dev/examples/llamaindex.md # LlamaIndex +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + LlamaIndex with `donkey.llamaindex.llm("…")`, an `OpenAILike`. The adapter sets `is_chat_model=True` — the `OpenAILike` default of `False` hits `/completions` — so calls go to the proxy's `/chat/completions` route. Sync @@ -9623,6 +9690,8 @@ Source: https://docs.donkey-kit.dev/examples/adk.md # Google ADK +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + Google ADK with `donkey.adk.model("…")`, a `LiteLlm` model. LiteLLM calls the proxy's `/chat/completions` route. It sends through the SDK's shared HTTP client, so the credentials and the run id go on the wire. LiteLLM raises its own errors, so a refusal becomes a **typed @@ -9724,6 +9793,8 @@ Source: https://docs.donkey-kit.dev/examples/anthropic.md # Anthropic +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + The native `anthropic` client from `donkey.anthropic.client()`, on the governed transport. It sends `/v1/messages`, so it needs a proxy provisioned **`Format=Anthropic`** (for example `ddk-anthropic-inbound`). The default DDK @@ -9834,6 +9905,8 @@ Source: https://docs.donkey-kit.dev/examples/gemini.md # Gemini +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + These scripts show the wire: plain `httpx` against a proxy provisioned **`Format=Gemini`** (for example `ddk-gemini-inbound`) with the same `client_id` / `client_secret` pair. For an agent, ADK's native `Gemini` model is @@ -10271,6 +10344,8 @@ Source: https://docs.donkey-kit.dev/reference/configuration.md # Configuration +Status: Live. Shipped, and verified against a real governed gateway. + `Donkey.from_env(...)` builds its configuration from the values you pass, then environment variables, then the config files, then defaults. `Donkey(DonkeyConfig(...))` uses only what you pass. See [Building a config](#building-a-config) for both paths, @@ -11137,6 +11212,8 @@ Source: https://docs.donkey-kit.dev/reference/api.md # Python API reference +Status: Live. Shipped, and verified against a real governed gateway. + The public API of `donkey-kit`, generated from its signatures and docstrings. Each entry shows the first paragraph of the docstring; `help()` on the object prints the rest. Everything listed here is @@ -11149,7 +11226,7 @@ The SDK entry point: one governed handle on Agent Fabric. Each framework adapter is an attribute too, listed under [Framework adapters](#framework-adapters). -```python +```python nocheck Donkey( config: DonkeyConfig | None = None, *, @@ -11160,7 +11237,7 @@ Donkey( ### `Donkey.from_env()` -```python +```python nocheck Donkey.from_env( *, team: str | None = None, @@ -11178,7 +11255,7 @@ Guide: [`/reference/configuration#precedence`](https://docs.donkey-kit.dev/refer ### `donkey.config` -```python +```python nocheck donkey.config: DonkeyConfig ``` @@ -11188,7 +11265,7 @@ Guide: [`/reference/configuration`](https://docs.donkey-kit.dev/reference/config ### `donkey.llm` -```python +```python nocheck donkey.llm: LLMClient ``` @@ -11198,7 +11275,7 @@ Guide: [`/quickstart`](https://docs.donkey-kit.dev/quickstart.md) ### `donkey.budget` -```python +```python nocheck donkey.budget: Budget ``` @@ -11206,7 +11283,7 @@ The token-budget window for this Donkey, updated in-band from every response's ` ### `donkey.last_call` -```python +```python nocheck donkey.last_call: LastCall ``` @@ -11214,7 +11291,7 @@ The gateway's own metadata about the most recent governed model call in this con ### `donkey.openai()` -```python +```python nocheck donkey.openai(*, sync: bool = False, **kw: Any) -> AsyncOpenAI | OpenAI ``` @@ -11222,7 +11299,7 @@ The headline two-line ergonomic: a native OpenAI client pointed at the governed ### `donkey.http_client()` -```python +```python nocheck donkey.http_client(*, sync: bool = False) -> DonkeyAsyncClientView | DonkeyClientView ``` @@ -11230,7 +11307,7 @@ The governed data-plane `httpx` client, for a framework or SDK that takes one (` ### `donkey.registry` -```python +```python nocheck donkey.registry: ExchangeRegistry ``` @@ -11240,7 +11317,7 @@ Guide: [`/tool-access/discovery`](https://docs.donkey-kit.dev/tool-access/discov ### `donkey.tools` -```python +```python nocheck donkey.tools: ToolsFacade ``` @@ -11250,7 +11327,7 @@ Guide: [`/tool-access`](https://docs.donkey-kit.dev/tool-access.md) ### `donkey.run()` -```python +```python nocheck donkey.run( id: str | None = None, *, @@ -11266,7 +11343,7 @@ Group one logical agent run under a shared correlation id. ### `donkey.run_context()` -```python +```python nocheck donkey.run_context(run_id: str | None = None) -> RunScope ``` @@ -11274,7 +11351,7 @@ Deprecated alias for `run`: use `donkey.run(id=…)`. Emits a `DeprecationWarnin ### `donkey.cache()` -```python +```python nocheck donkey.cache( *, skip: bool | None = None, @@ -11289,7 +11366,7 @@ Steer the gateway's **semantic cache** for every governed model call in the bloc ### `donkey.governed()` -```python +```python nocheck donkey.governed( func: Callable[P, R] | None = None, *, @@ -11305,7 +11382,7 @@ Wrap a callable so its body runs inside a `donkey.run()` scope. ### `donkey.tool()` -```python +```python nocheck donkey.tool(func: Callable) -> Callable ``` @@ -11313,7 +11390,7 @@ Mark a callable as a governed tool **without changing call behaviour**, recordin ### `donkey.simulate()` -```python +```python nocheck donkey.simulate( error: type[DonkeyError], *, @@ -11325,7 +11402,7 @@ Inject a real gateway refusal in-process, no server. ### `donkey.aclose()` -```python +```python nocheck async donkey.aclose() -> None ``` @@ -11333,7 +11410,7 @@ Close every transport this Donkey owns: the data-plane and control-plane clients ### `donkey.close()` -```python +```python nocheck donkey.close() -> None ``` @@ -11347,7 +11424,7 @@ Its fields are listed in [Configuration](https://docs.donkey-kit.dev/reference/c ### `DonkeyConfig.resolve()` -```python +```python nocheck DonkeyConfig.resolve( *, path: str | os.PathLike[str] | None = None, @@ -11361,7 +11438,7 @@ Guide: [`/reference/configuration#precedence`](https://docs.donkey-kit.dev/refer ### `DonkeyConfig.from_env()` -```python +```python nocheck DonkeyConfig.from_env() -> DonkeyConfig ``` @@ -11369,7 +11446,7 @@ Build from env + the optional config files: `resolve` with no arguments (see the ### `config.control_plane_url` -```python +```python nocheck config.control_plane_url: str ``` @@ -11377,7 +11454,7 @@ The Anypoint control-plane base URL — explicit override or region. ### `config.source_of()` -```python +```python nocheck config.source_of(name: str) -> ConfigSource ``` @@ -11385,7 +11462,7 @@ Where field `name` was resolved from. `explicit` (set in code) when `resolve` di ### `config.with_overrides()` -```python +```python nocheck config.with_overrides(**kw: Unpack[ConfigOverrides]) -> DonkeyConfig ``` @@ -11393,7 +11470,7 @@ Return a copy with the given fields replaced. ### `config.validated()` -```python +```python nocheck config.validated(*, need: Capability = 'control_plane') -> DonkeyConfig ``` @@ -11401,7 +11478,7 @@ Return self if valid for the requested capability, else raise a `ConfigError` li ### `config.missing_fields()` -```python +```python nocheck config.missing_fields(*, need: Capability) -> list[str] ``` @@ -11409,7 +11486,7 @@ Every required field for `need` that is unset, each with the env var that sets i ### `config.check_endpoints()` -```python +```python nocheck config.check_endpoints(*, need: Capability, code_credential: str | None = None) -> None ``` @@ -11421,7 +11498,7 @@ The framework-free proxy client factory. ### `donkey.llm.close()` -```python +```python nocheck donkey.llm.close() -> None ``` @@ -11429,7 +11506,7 @@ Close the blocking client built by this standalone factory, if any. ### `donkey.llm.aclose()` -```python +```python nocheck async donkey.llm.aclose() -> None ``` @@ -11437,7 +11514,7 @@ Close this factory's owned blocking client in an async scope. ### `donkey.llm.client()` -```python +```python nocheck donkey.llm.client(*, sync: bool = False, **kw: Any) -> AsyncOpenAI | OpenAI ``` @@ -11445,7 +11522,7 @@ An OpenAI client pointed at the LLM proxy, using our shared http client so heade ### `donkey.llm.list_models()` -```python +```python nocheck async donkey.llm.list_models(*, live: bool = False) -> list[ModelHandle] ``` @@ -11453,7 +11530,7 @@ List logical models the proxy exposes. ### `donkey.llm.resolve()` -```python +```python nocheck donkey.llm.resolve(model_id: str, *, provider: str | None = None) -> ModelHandle ``` @@ -11465,7 +11542,7 @@ The token-budget window for one `Donkey`, updated in-band from each response's b ### `donkey.budget.observe()` -```python +```python nocheck donkey.budget.observe(response: httpx.Response, *, now: datetime | None = None) -> None ``` @@ -11473,7 +11550,7 @@ Update from a response's budget headers. ### `donkey.budget.pace()` -```python +```python nocheck donkey.budget.pace( *, reserve: float = 0.0, @@ -11485,7 +11562,7 @@ Guard a request so it is refused *before* it crosses your reserve, not after a 4 ### `donkey.budget.wait_for_reset()` -```python +```python nocheck async donkey.budget.wait_for_reset( *, window: str | None = None, @@ -11503,7 +11580,7 @@ Its fields are listed in [`last_call` fields](https://docs.donkey-kit.dev/refere ### `donkey.last_call.observed` -```python +```python nocheck donkey.last_call.observed: bool ``` @@ -11511,7 +11588,7 @@ True iff a governed response actually populated this record. ### `donkey.last_call.substituted` -```python +```python nocheck donkey.last_call.substituted: bool ``` @@ -11519,7 +11596,7 @@ True iff the gateway served a *different* model than the one requested — a sil ### `donkey.last_call.cache_hit` -```python +```python nocheck donkey.last_call.cache_hit: bool ``` @@ -11527,7 +11604,7 @@ True iff the gateway served this call from its semantic cache (`cache_status` == ### `donkey.last_call.available` -```python +```python nocheck donkey.last_call.available: bool ``` @@ -11535,7 +11612,7 @@ False only when the current surface structurally cannot be observed (the framewo ### `LastCall.from_response()` -```python +```python nocheck LastCall.from_response( response: httpx.Response, *, @@ -11620,6 +11697,8 @@ Source: https://docs.donkey-kit.dev/reference/last-call.md # `last_call` field reference +Status: Live. Shipped, and verified against a real governed gateway. + `donkey.last_call` is what the gateway said about the **most recent governed model call** in the current context. It is an immutable snapshot: every governed response replaces the record wholesale rather than mutating it, so a reader @@ -11750,6 +11829,8 @@ Source: https://docs.donkey-kit.dev/reference/unsupported-boundary.md # Unsupported boundary +Status: Live. Shipped, and verified against a real governed gateway. + Which platform APIs does the SDK call, and are they supported for third-party use? This page answers that question for security and procurement reviews. diff --git a/website/public/llms.txt b/website/public/llms.txt index be206842..9ea3f8f3 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -5,89 +5,89 @@ ## Get started - [Introduction](https://docs.donkey-kit.dev/index.md): Governed by the gateway, understood by your code. DDK brings Agent Fabric and Omni Gateway awareness into your agent framework — typed refusals, budgets, telemetry and simulation, with native framework objects. -- [Quickstart](https://docs.donkey-kit.dev/quickstart.md): From pip install to a governed model call, a typed refusal, a budget and an OpenTelemetry span — locally, with no gateway and no credentials. -- [Feature overview](https://docs.donkey-kit.dev/feature-overview.md): Every DDK capability at a glance — its purpose, its goal, and what it saves developers — grouped by pillar. +- [Quickstart](https://docs.donkey-kit.dev/quickstart.md): (status: live) From pip install to a governed model call, a typed refusal, a budget and an OpenTelemetry span — locally, with no gateway and no credentials. +- [Feature overview](https://docs.donkey-kit.dev/feature-overview.md): (status: live) Every DDK capability at a glance — its purpose, its goal, and what it saves developers — grouped by pillar. ## Frameworks -- [Overview](https://docs.donkey-kit.dev/frameworks.md): Governed model access from eight agent frameworks. Each adapter returns the framework's own native model or client object, pointed at your Omni Gateway LLM proxy. -- [LangGraph](https://docs.donkey-kit.dev/frameworks/langgraph.md): Use a governed LangChain ChatOpenAI in LangGraph, pointed at your Omni Gateway LLM proxy with full header and transport injection, typed refusals, and conformance testing. -- [Google ADK](https://docs.donkey-kit.dev/frameworks/adk.md): Use a governed LiteLlm model, or ADK's native Gemini model on a Format=Gemini proxy, in Google's Agent Development Kit (ADK), pointed at your Omni Gateway LLM proxy. -- [Strands](https://docs.donkey-kit.dev/frameworks/strands.md): Use a governed OpenAIModel in Strands Agents, pointed at your Omni Gateway LLM proxy with full header and transport injection. -- [MS Agent Framework](https://docs.donkey-kit.dev/frameworks/agent-framework.md): Use a governed OpenAIChatClient in Microsoft Agent Framework, pointed at your Omni Gateway LLM proxy, with policy middleware that ends a run on a typed governance refusal. -- [OpenAI Agents SDK](https://docs.donkey-kit.dev/frameworks/openai.md): Use a governed OpenAIChatCompletionsModel in the OpenAI Agents SDK, backed by a pre-built AsyncOpenAI client pointed at your Omni Gateway LLM proxy. -- [Anthropic SDK](https://docs.donkey-kit.dev/frameworks/anthropic.md): Use a governed AsyncAnthropic client with the Anthropic SDK, pointed at a Format=Anthropic Omni Gateway LLM proxy with full header and transport injection. -- [CrewAI](https://docs.donkey-kit.dev/frameworks/crewai.md): Use a governed CrewAI LLM, served by CrewAI's native OpenAI provider and pointed at your Omni Gateway LLM proxy. -- [LlamaIndex](https://docs.donkey-kit.dev/frameworks/llamaindex.md): Use a governed OpenAILike LLM in LlamaIndex, pointed at your Omni Gateway LLM proxy with is_chat_model forced on. +- [Overview](https://docs.donkey-kit.dev/frameworks.md): (status: offline-verified) Governed model access from eight agent frameworks. Each adapter returns the framework's own native model or client object, pointed at your Omni Gateway LLM proxy. +- [LangGraph](https://docs.donkey-kit.dev/frameworks/langgraph.md): (status: offline-verified) Use a governed LangChain ChatOpenAI in LangGraph, pointed at your Omni Gateway LLM proxy with full header and transport injection, typed refusals, and conformance testing. +- [Google ADK](https://docs.donkey-kit.dev/frameworks/adk.md): (status: offline-verified) Use a governed LiteLlm model, or ADK's native Gemini model on a Format=Gemini proxy, in Google's Agent Development Kit (ADK), pointed at your Omni Gateway LLM proxy. +- [Strands](https://docs.donkey-kit.dev/frameworks/strands.md): (status: offline-verified) Use a governed OpenAIModel in Strands Agents, pointed at your Omni Gateway LLM proxy with full header and transport injection. +- [MS Agent Framework](https://docs.donkey-kit.dev/frameworks/agent-framework.md): (status: offline-verified) Use a governed OpenAIChatClient in Microsoft Agent Framework, pointed at your Omni Gateway LLM proxy, with policy middleware that ends a run on a typed governance refusal. +- [OpenAI Agents SDK](https://docs.donkey-kit.dev/frameworks/openai.md): (status: offline-verified) Use a governed OpenAIChatCompletionsModel in the OpenAI Agents SDK, backed by a pre-built AsyncOpenAI client pointed at your Omni Gateway LLM proxy. +- [Anthropic SDK](https://docs.donkey-kit.dev/frameworks/anthropic.md): (status: offline-verified) Use a governed AsyncAnthropic client with the Anthropic SDK, pointed at a Format=Anthropic Omni Gateway LLM proxy with full header and transport injection. +- [CrewAI](https://docs.donkey-kit.dev/frameworks/crewai.md): (status: offline-verified) Use a governed CrewAI LLM, served by CrewAI's native OpenAI provider and pointed at your Omni Gateway LLM proxy. +- [LlamaIndex](https://docs.donkey-kit.dev/frameworks/llamaindex.md): (status: offline-verified) Use a governed OpenAILike LLM in LlamaIndex, pointed at your Omni Gateway LLM proxy with is_chat_model forced on. ## Governance -- [Typed refusals](https://docs.donkey-kit.dev/errors.md): Typed exceptions for every governance refusal, each with a discriminator, a retry rule, and a next step. -- [Budget & pacing](https://docs.donkey-kit.dev/budget.md): Remaining token budget as a first-class object, with pacing that slows down before it hits the wall. -- [Identity](https://docs.donkey-kit.dev/identity.md): On-behalf-of token exchange, so the gateway can enforce per-user policy instead of seeing only the service. -- [Human-in-the-loop](https://docs.donkey-kit.dev/hitl.md): One vocabulary for pause-and-ask-a-human, mapped onto each framework's native interrupt. -- [Policy handshake](https://docs.donkey-kit.dev/policies.md): Read the in-force policy set to avoid calls that will be refused — advisory only; the gateway always wins. +- [Typed refusals](https://docs.donkey-kit.dev/errors.md): (status: live) Typed exceptions for every governance refusal, each with a discriminator, a retry rule, and a next step. +- [Budget & pacing](https://docs.donkey-kit.dev/budget.md): (status: live) Remaining token budget as a first-class object, with pacing that slows down before it hits the wall. +- [Identity](https://docs.donkey-kit.dev/identity.md): (status: roadmap) On-behalf-of token exchange, so the gateway can enforce per-user policy instead of seeing only the service. +- [Human-in-the-loop](https://docs.donkey-kit.dev/hitl.md): (status: roadmap) One vocabulary for pause-and-ask-a-human, mapped onto each framework's native interrupt. +- [Policy handshake](https://docs.donkey-kit.dev/policies.md): (status: roadmap) Read the in-force policy set to avoid calls that will be refused — advisory only; the gateway always wins. ## Observability -- [Telemetry & cost](https://docs.donkey-kit.dev/telemetry.md): OpenTelemetry GenAI spans, per-run correlation IDs, and cost-attribution tags — in the dashboard you already run. +- [Telemetry & cost](https://docs.donkey-kit.dev/telemetry.md): (status: live) OpenTelemetry GenAI spans, per-run correlation IDs, and cost-attribution tags — in the dashboard you already run. ## Developer tooling -- [Local simulator](https://docs.donkey-kit.dev/simulator.md): Run a local server that replays real gateway rejections, so you can build and test failure handling without a governed gateway. -- [Testing & conformance](https://docs.donkey-kit.dev/testing.md): Test your agent's failure handling — in-process refusal injection, a pytest conformance suite you run against your own agent, and an out-of-process gateway fixture. -- [CLI & decorators](https://docs.donkey-kit.dev/cli.md): The @donkey.governed and @donkey.tool decorators, and the donkey CLI — init, doctor, mock, and test. -- [Troubleshooting](https://docs.donkey-kit.dev/troubleshooting.md): How to read donkey doctor output, tell apart failures that look the same, explain an UNAVAILABLE donkey.last_call, and fix a 401 in JWT mode. -- [Use with your coding agent](https://docs.donkey-kit.dev/use-with-your-agent.md): Point your coding assistant at these docs — llms.txt, llms-full.txt, and a .md next to every page — so it writes correct governed code. +- [Local simulator](https://docs.donkey-kit.dev/simulator.md): (status: live) Run a local server that replays real gateway rejections, so you can build and test failure handling without a governed gateway. +- [Testing & conformance](https://docs.donkey-kit.dev/testing.md): (status: live) Test your agent's failure handling — in-process refusal injection, a pytest conformance suite you run against your own agent, and an out-of-process gateway fixture. +- [CLI & decorators](https://docs.donkey-kit.dev/cli.md): (status: live) The @donkey.governed and @donkey.tool decorators, and the donkey CLI — init, doctor, mock, and test. +- [Troubleshooting](https://docs.donkey-kit.dev/troubleshooting.md): (status: live) How to read donkey doctor output, tell apart failures that look the same, explain an UNAVAILABLE donkey.last_call, and fix a 401 in JWT mode. +- [Use with your coding agent](https://docs.donkey-kit.dev/use-with-your-agent.md): (status: live) Point your coding assistant at these docs — llms.txt, llms-full.txt, and a .md next to every page — so it writes correct governed code. ## Tool access -- [Overview](https://docs.donkey-kit.dev/tool-access.md): Discover the governed MCP tools your organisation publishes in Exchange and bind them into any framework as native tool objects. -- [Discovery, search & filter](https://docs.donkey-kit.dev/tool-access/discovery.md): Find governed MCP tools by governance criteria, domain and tags, search the registry by name, asset type and environment, and see why any tool was included or excluded. -- [Framework binding](https://docs.donkey-kit.dev/tool-access/binding.md): Turn discovered governed MCP servers into each framework's native tool objects, with auth, lifecycle, name collisions, and filtering handled for you. -- [Pinning & lockfile](https://docs.donkey-kit.dev/tool-access/lockfile.md): Pin governed tool versions and content digests in a donkey.lock so catalog changes become reviewable diffs, not runtime surprises. -- [A2A agent tools](https://docs.donkey-kit.dev/tool-access/a2a.md): Bind a governed A2A agent from the registry as a native tool, so your agent can delegate to it without learning the A2A protocol. +- [Overview](https://docs.donkey-kit.dev/tool-access.md): (status: roadmap) Discover the governed MCP tools your organisation publishes in Exchange and bind them into any framework as native tool objects. +- [Discovery, search & filter](https://docs.donkey-kit.dev/tool-access/discovery.md): (status: roadmap) Find governed MCP tools by governance criteria, domain and tags, search the registry by name, asset type and environment, and see why any tool was included or excluded. +- [Framework binding](https://docs.donkey-kit.dev/tool-access/binding.md): (status: roadmap) Turn discovered governed MCP servers into each framework's native tool objects, with auth, lifecycle, name collisions, and filtering handled for you. +- [Pinning & lockfile](https://docs.donkey-kit.dev/tool-access/lockfile.md): (status: roadmap) Pin governed tool versions and content digests in a donkey.lock so catalog changes become reviewable diffs, not runtime surprises. +- [A2A agent tools](https://docs.donkey-kit.dev/tool-access/a2a.md): (status: roadmap) Bind a governed A2A agent from the registry as a native tool, so your agent can delegate to it without learning the A2A protocol. ## Registry & catalog -- [A2A agents](https://docs.donkey-kit.dev/a2a.md): Make your agent callable by other agents over A2A — serve it locally, expose it through the gateway, and develop against a gateway on your laptop. -- [Scan & publish](https://docs.donkey-kit.dev/publishing.md): Derive a manifest and agent card from your code, then register them with Exchange / Agent Registry from CI — the Agent Scanner for your git repo. +- [A2A agents](https://docs.donkey-kit.dev/a2a.md): (status: roadmap) Make your agent callable by other agents over A2A — serve it locally, expose it through the gateway, and develop against a gateway on your laptop. +- [Scan & publish](https://docs.donkey-kit.dev/publishing.md): (status: roadmap) Derive a manifest and agent card from your code, then register them with Exchange / Agent Registry from CI — the Agent Scanner for your git repo. ## Scenarios -- [Overview](https://docs.donkey-kit.dev/scenarios.md): Three end-to-end walkthroughs — support triage, an overnight batch job, and an internal copilot — each showing the governance layer a bare base_url cannot give you. -- [Support triage](https://docs.donkey-kit.dev/scenarios/support-triage.md): Draft replies to a queue of support tickets, one carrying PII, with typed refusals, correlation IDs, and OTel spans — the shipped LangGraph demo, end to end, no gateway. -- [Nightly batch](https://docs.donkey-kit.dev/scenarios/nightly-batch.md): Enrich tens of thousands of records overnight against a windowed token budget, unattended — pacing before the wall and resuming after the window resets, tested end to end against the simulator. -- [Internal copilot](https://docs.donkey-kit.dev/scenarios/internal-copilot.md): An internal assistant whose output must clear a content-safety guardrail — the ContentSafetyBlocked branch driven by the simulator, with per-run correlation joining the refusal to your logs. +- [Overview](https://docs.donkey-kit.dev/scenarios.md): (status: live) Three end-to-end walkthroughs — support triage, an overnight batch job, and an internal copilot — each showing the governance layer a bare base_url cannot give you. +- [Support triage](https://docs.donkey-kit.dev/scenarios/support-triage.md): (status: offline-verified) Draft replies to a queue of support tickets, one carrying PII, with typed refusals, correlation IDs, and OTel spans — the shipped LangGraph demo, end to end, no gateway. +- [Nightly batch](https://docs.donkey-kit.dev/scenarios/nightly-batch.md): (status: live) Enrich tens of thousands of records overnight against a windowed token budget, unattended — pacing before the wall and resuming after the window resets, tested end to end against the simulator. +- [Internal copilot](https://docs.donkey-kit.dev/scenarios/internal-copilot.md): (status: live) An internal assistant whose output must clear a content-safety guardrail — the ContentSafetyBlocked branch driven by the simulator, with per-run correlation joining the refusal to your logs. ## Examples -- [Overview](https://docs.donkey-kit.dev/examples.md): Runnable DDK examples from the companion demos repo — narrative demos that run offline against the local simulator, and short per-framework scripts for a live gateway. +- [Overview](https://docs.donkey-kit.dev/examples.md): (status: live) Runnable DDK examples from the companion demos repo — narrative demos that run offline against the local simulator, and short per-framework scripts for a live gateway. ## General -- [Governed client](https://docs.donkey-kit.dev/examples/general/governed-client.md): A stock OpenAI client next to a governed one, the same PII refusal through both, and the @donkey.governed / @donkey.tool decorators. -- [Typed refusals](https://docs.donkey-kit.dev/examples/general/typed-refusals.md): Every captured gateway rejection shape mapped to a typed exception with classify(), live refusals on a blocking client, and GatewayUnavailable when nothing answers. -- [Budget & pacing](https://docs.donkey-kit.dev/examples/general/budget-and-pacing.md): The gateway's token window as an object, pace(reserve=) refusing locally before a request would cross your reserve, and wait_for_reset(). -- [Simulating refusals](https://docs.donkey-kit.dev/examples/general/simulating-refusals.md): Run the refusal branch of your agent without a gateway — donkey.simulate() in-process, and donkey mock --scenario for a stock client. -- [Conformance suite](https://docs.donkey-kit.dev/examples/general/conformance.md): pytest --donkey-conformance grading an agent's refusal handling, budget retries and correlation logging — first a naive agent, then the fixed one, then exemptions. -- [Telemetry](https://docs.donkey-kit.dev/examples/general/telemetry.md): OpenTelemetry GenAI spans with no instrumentation code, one correlation id per donkey.run(), refusal spans, and zero-config OTLP export. -- [Framework objects & model handles](https://docs.donkey-kit.dev/examples/general/framework-objects.md): Framework adapters that return your framework's own objects, connection_kwargs() for the rest, local model capability handles, and config validation that reports everything at once. -- [LangGraph agent](https://docs.donkey-kit.dev/examples/general/langgraph-agent.md): A real LangGraph tool-calling agent governed end to end — LangChain's own ChatOpenAI, one run id around the loop, and typed refusals out of astream. -- [Gateway identity (last_call)](https://docs.donkey-kit.dev/examples/general/gateway-identity.md): donkey.last_call on a successful call — which gateway served it, what it routed to, per-call token usage — and on_model_substitution="raise" turning a silent model swap into ModelSubstituted. +- [Governed client](https://docs.donkey-kit.dev/examples/general/governed-client.md): (status: live) A stock OpenAI client next to a governed one, the same PII refusal through both, and the @donkey.governed / @donkey.tool decorators. +- [Typed refusals](https://docs.donkey-kit.dev/examples/general/typed-refusals.md): (status: live) Every captured gateway rejection shape mapped to a typed exception with classify(), live refusals on a blocking client, and GatewayUnavailable when nothing answers. +- [Budget & pacing](https://docs.donkey-kit.dev/examples/general/budget-and-pacing.md): (status: live) The gateway's token window as an object, pace(reserve=) refusing locally before a request would cross your reserve, and wait_for_reset(). +- [Simulating refusals](https://docs.donkey-kit.dev/examples/general/simulating-refusals.md): (status: live) Run the refusal branch of your agent without a gateway — donkey.simulate() in-process, and donkey mock --scenario for a stock client. +- [Conformance suite](https://docs.donkey-kit.dev/examples/general/conformance.md): (status: live) pytest --donkey-conformance grading an agent's refusal handling, budget retries and correlation logging — first a naive agent, then the fixed one, then exemptions. +- [Telemetry](https://docs.donkey-kit.dev/examples/general/telemetry.md): (status: live) OpenTelemetry GenAI spans with no instrumentation code, one correlation id per donkey.run(), refusal spans, and zero-config OTLP export. +- [Framework objects & model handles](https://docs.donkey-kit.dev/examples/general/framework-objects.md): (status: offline-verified) Framework adapters that return your framework's own objects, connection_kwargs() for the rest, local model capability handles, and config validation that reports everything at once. +- [LangGraph agent](https://docs.donkey-kit.dev/examples/general/langgraph-agent.md): (status: offline-verified) A real LangGraph tool-calling agent governed end to end — LangChain's own ChatOpenAI, one run id around the loop, and typed refusals out of astream. +- [Gateway identity (last_call)](https://docs.donkey-kit.dev/examples/general/gateway-identity.md): (status: live) donkey.last_call on a successful call — which gateway served it, what it routed to, per-call token usage — and on_model_substitution="raise" turning a silent model swap into ModelSubstituted. ## Examples -- [OpenAI](https://docs.donkey-kit.dev/examples/openai.md): Sixteen straight-line scripts for the stock openai client governed by donkey.openai() — last_call, typed refusals, budget pacing, spans, decorators, streaming, guardrails, JWT wallets and the local simulator. -- [LangGraph](https://docs.donkey-kit.dev/examples/langgraph.md): Nine LangGraph scripts — a real ChatOpenAI on /chat/completions, typed refusals out of create_agent, the run id reaching tools, streaming, spans and the local simulator. -- [OpenAI Agents SDK](https://docs.donkey-kit.dev/examples/openai-agents.md): Three OpenAI Agents SDK scripts — an OpenAIResponsesModel on the governed client, a tool-calling Runner, typed refusals and the local simulator. -- [MS Agent Framework](https://docs.donkey-kit.dev/examples/agent-framework.md): Three Microsoft Agent Framework scripts — an OpenAIChatClient built by donkey.agent_framework, typed refusals unwrapped from ChatClientException, and the local simulator. -- [Strands](https://docs.donkey-kit.dev/examples/strands.md): Four Strands Agents scripts — OpenAIModel on a pre-built governed client, the run id reaching a tool, typed refusals simulated and live. -- [CrewAI](https://docs.donkey-kit.dev/examples/crewai.md): Two CrewAI scripts — donkey.crewai.llm() for a direct call and a one-agent crew, and typed refusals from CrewAI's native OpenAI provider. -- [LlamaIndex](https://docs.donkey-kit.dev/examples/llamaindex.md): Two LlamaIndex scripts — an OpenAILike from donkey.llamaindex.llm() for complete() and chat(), and typed refusals from its unchanged openai errors. -- [Google ADK](https://docs.donkey-kit.dev/examples/adk.md): Two Google ADK scripts — a LiteLlm model from donkey.adk.model() in an InMemoryRunner, and a PII refusal raised outside donkey.run(), where it stays LiteLLM's error. -- [Anthropic](https://docs.donkey-kit.dev/examples/anthropic.md): Two scripts for the native anthropic client from donkey.anthropic.client() — /v1/messages on a Format=Anthropic proxy with last_call, and typed refusals simulated in-process. -- [Gemini](https://docs.donkey-kit.dev/examples/gemini.md): Two plain-httpx scripts against a Format=Gemini proxy — native generateContent, and an OpenAI-shaped request classified as UpstreamRequestError. +- [OpenAI](https://docs.donkey-kit.dev/examples/openai.md): (status: offline-verified) Sixteen straight-line scripts for the stock openai client governed by donkey.openai() — last_call, typed refusals, budget pacing, spans, decorators, streaming, guardrails, JWT wallets and the local simulator. +- [LangGraph](https://docs.donkey-kit.dev/examples/langgraph.md): (status: offline-verified) Nine LangGraph scripts — a real ChatOpenAI on /chat/completions, typed refusals out of create_agent, the run id reaching tools, streaming, spans and the local simulator. +- [OpenAI Agents SDK](https://docs.donkey-kit.dev/examples/openai-agents.md): (status: offline-verified) Three OpenAI Agents SDK scripts — an OpenAIResponsesModel on the governed client, a tool-calling Runner, typed refusals and the local simulator. +- [MS Agent Framework](https://docs.donkey-kit.dev/examples/agent-framework.md): (status: offline-verified) Three Microsoft Agent Framework scripts — an OpenAIChatClient built by donkey.agent_framework, typed refusals unwrapped from ChatClientException, and the local simulator. +- [Strands](https://docs.donkey-kit.dev/examples/strands.md): (status: offline-verified) Four Strands Agents scripts — OpenAIModel on a pre-built governed client, the run id reaching a tool, typed refusals simulated and live. +- [CrewAI](https://docs.donkey-kit.dev/examples/crewai.md): (status: offline-verified) Two CrewAI scripts — donkey.crewai.llm() for a direct call and a one-agent crew, and typed refusals from CrewAI's native OpenAI provider. +- [LlamaIndex](https://docs.donkey-kit.dev/examples/llamaindex.md): (status: offline-verified) Two LlamaIndex scripts — an OpenAILike from donkey.llamaindex.llm() for complete() and chat(), and typed refusals from its unchanged openai errors. +- [Google ADK](https://docs.donkey-kit.dev/examples/adk.md): (status: offline-verified) Two Google ADK scripts — a LiteLlm model from donkey.adk.model() in an InMemoryRunner, and a PII refusal raised outside donkey.run(), where it stays LiteLLM's error. +- [Anthropic](https://docs.donkey-kit.dev/examples/anthropic.md): (status: offline-verified) Two scripts for the native anthropic client from donkey.anthropic.client() — /v1/messages on a Format=Anthropic proxy with last_call, and typed refusals simulated in-process. +- [Gemini](https://docs.donkey-kit.dev/examples/gemini.md): (status: offline-verified) Two plain-httpx scripts against a Format=Gemini proxy — native generateContent, and an OpenAI-shaped request classified as UpstreamRequestError. ## Roadmap @@ -101,7 +101,7 @@ ## Reference -- [Configuration](https://docs.donkey-kit.dev/reference/configuration.md): Every DDK configuration value — LLM proxy URL and credentials, JWT model-wallet auth, attribution, cost tags, telemetry, and Anypoint control-plane settings — as env vars, config file, or code. -- [Python API](https://docs.donkey-kit.dev/reference/api.md): The public Python API of donkey-kit — Donkey, DonkeyConfig, the model client, the budget window, last_call, the adapters and every export — generated from the code. -- [last_call fields](https://docs.donkey-kit.dev/reference/last-call.md): Every field on donkey.last_call — the observability status, the gateway identity, the routing and fallback signals, the per-call token usage, and the semantic-cache outcome the gateway reported about the most recent governed model call. -- [Unsupported boundary](https://docs.donkey-kit.dev/reference/unsupported-boundary.md): Which MuleSoft platform APIs DDK calls, whether each is documented for third-party use, and the project's support statement. +- [Configuration](https://docs.donkey-kit.dev/reference/configuration.md): (status: live) Every DDK configuration value — LLM proxy URL and credentials, JWT model-wallet auth, attribution, cost tags, telemetry, and Anypoint control-plane settings — as env vars, config file, or code. +- [Python API](https://docs.donkey-kit.dev/reference/api.md): (status: live) The public Python API of donkey-kit — Donkey, DonkeyConfig, the model client, the budget window, last_call, the adapters and every export — generated from the code. +- [last_call fields](https://docs.donkey-kit.dev/reference/last-call.md): (status: live) Every field on donkey.last_call — the observability status, the gateway identity, the routing and fallback signals, the per-call token usage, and the semantic-cache outcome the gateway reported about the most recent governed model call. +- [Unsupported boundary](https://docs.donkey-kit.dev/reference/unsupported-boundary.md): (status: live) Which MuleSoft platform APIs DDK calls, whether each is documented for third-party use, and the project's support statement. diff --git a/website/public/policies.md b/website/public/policies.md index 9332f083..fde37452 100644 --- a/website/public/policies.md +++ b/website/public/policies.md @@ -1,6 +1,6 @@ # Policy handshake -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. It depends on a policy-discovery endpoint on the gateway, which does not diff --git a/website/public/publishing.md b/website/public/publishing.md index 1c0ac790..f96e4cf8 100644 --- a/website/public/publishing.md +++ b/website/public/publishing.md @@ -1,6 +1,6 @@ # Scan & publish -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/quickstart.md b/website/public/quickstart.md index 68e955bc..dd087957 100644 --- a/website/public/quickstart.md +++ b/website/public/quickstart.md @@ -1,5 +1,7 @@ # Quickstart +Status: Live. Shipped, and verified against a real governed gateway. + Make your first governed model call, then see what a plain `base_url` can't give you — a **typed refusal**, a **budget** and a **span** — in a few minutes, on your laptop. You start against DDK's **local gateway simulator**, diff --git a/website/public/reference/api.md b/website/public/reference/api.md index f3d341ae..d63b2b2f 100644 --- a/website/public/reference/api.md +++ b/website/public/reference/api.md @@ -1,5 +1,7 @@ # Python API reference +Status: Live. Shipped, and verified against a real governed gateway. + The public API of `donkey-kit`, generated from its signatures and docstrings. Each entry shows the first paragraph of the docstring; `help()` on the object prints the rest. Everything listed here is @@ -12,7 +14,7 @@ The SDK entry point: one governed handle on Agent Fabric. Each framework adapter is an attribute too, listed under [Framework adapters](#framework-adapters). -```python +```python nocheck Donkey( config: DonkeyConfig | None = None, *, @@ -23,7 +25,7 @@ Donkey( ### `Donkey.from_env()` -```python +```python nocheck Donkey.from_env( *, team: str | None = None, @@ -41,7 +43,7 @@ Guide: [`/reference/configuration#precedence`](https://docs.donkey-kit.dev/refer ### `donkey.config` -```python +```python nocheck donkey.config: DonkeyConfig ``` @@ -51,7 +53,7 @@ Guide: [`/reference/configuration`](https://docs.donkey-kit.dev/reference/config ### `donkey.llm` -```python +```python nocheck donkey.llm: LLMClient ``` @@ -61,7 +63,7 @@ Guide: [`/quickstart`](https://docs.donkey-kit.dev/quickstart.md) ### `donkey.budget` -```python +```python nocheck donkey.budget: Budget ``` @@ -69,7 +71,7 @@ The token-budget window for this Donkey, updated in-band from every response's ` ### `donkey.last_call` -```python +```python nocheck donkey.last_call: LastCall ``` @@ -77,7 +79,7 @@ The gateway's own metadata about the most recent governed model call in this con ### `donkey.openai()` -```python +```python nocheck donkey.openai(*, sync: bool = False, **kw: Any) -> AsyncOpenAI | OpenAI ``` @@ -85,7 +87,7 @@ The headline two-line ergonomic: a native OpenAI client pointed at the governed ### `donkey.http_client()` -```python +```python nocheck donkey.http_client(*, sync: bool = False) -> DonkeyAsyncClientView | DonkeyClientView ``` @@ -93,7 +95,7 @@ The governed data-plane `httpx` client, for a framework or SDK that takes one (` ### `donkey.registry` -```python +```python nocheck donkey.registry: ExchangeRegistry ``` @@ -103,7 +105,7 @@ Guide: [`/tool-access/discovery`](https://docs.donkey-kit.dev/tool-access/discov ### `donkey.tools` -```python +```python nocheck donkey.tools: ToolsFacade ``` @@ -113,7 +115,7 @@ Guide: [`/tool-access`](https://docs.donkey-kit.dev/tool-access.md) ### `donkey.run()` -```python +```python nocheck donkey.run( id: str | None = None, *, @@ -129,7 +131,7 @@ Group one logical agent run under a shared correlation id. ### `donkey.run_context()` -```python +```python nocheck donkey.run_context(run_id: str | None = None) -> RunScope ``` @@ -137,7 +139,7 @@ Deprecated alias for `run`: use `donkey.run(id=…)`. Emits a `DeprecationWarnin ### `donkey.cache()` -```python +```python nocheck donkey.cache( *, skip: bool | None = None, @@ -152,7 +154,7 @@ Steer the gateway's **semantic cache** for every governed model call in the bloc ### `donkey.governed()` -```python +```python nocheck donkey.governed( func: Callable[P, R] | None = None, *, @@ -168,7 +170,7 @@ Wrap a callable so its body runs inside a `donkey.run()` scope. ### `donkey.tool()` -```python +```python nocheck donkey.tool(func: Callable) -> Callable ``` @@ -176,7 +178,7 @@ Mark a callable as a governed tool **without changing call behaviour**, recordin ### `donkey.simulate()` -```python +```python nocheck donkey.simulate( error: type[DonkeyError], *, @@ -188,7 +190,7 @@ Inject a real gateway refusal in-process, no server. ### `donkey.aclose()` -```python +```python nocheck async donkey.aclose() -> None ``` @@ -196,7 +198,7 @@ Close every transport this Donkey owns: the data-plane and control-plane clients ### `donkey.close()` -```python +```python nocheck donkey.close() -> None ``` @@ -210,7 +212,7 @@ Its fields are listed in [Configuration](https://docs.donkey-kit.dev/reference/c ### `DonkeyConfig.resolve()` -```python +```python nocheck DonkeyConfig.resolve( *, path: str | os.PathLike[str] | None = None, @@ -224,7 +226,7 @@ Guide: [`/reference/configuration#precedence`](https://docs.donkey-kit.dev/refer ### `DonkeyConfig.from_env()` -```python +```python nocheck DonkeyConfig.from_env() -> DonkeyConfig ``` @@ -232,7 +234,7 @@ Build from env + the optional config files: `resolve` with no arguments (see the ### `config.control_plane_url` -```python +```python nocheck config.control_plane_url: str ``` @@ -240,7 +242,7 @@ The Anypoint control-plane base URL — explicit override or region. ### `config.source_of()` -```python +```python nocheck config.source_of(name: str) -> ConfigSource ``` @@ -248,7 +250,7 @@ Where field `name` was resolved from. `explicit` (set in code) when `resolve` di ### `config.with_overrides()` -```python +```python nocheck config.with_overrides(**kw: Unpack[ConfigOverrides]) -> DonkeyConfig ``` @@ -256,7 +258,7 @@ Return a copy with the given fields replaced. ### `config.validated()` -```python +```python nocheck config.validated(*, need: Capability = 'control_plane') -> DonkeyConfig ``` @@ -264,7 +266,7 @@ Return self if valid for the requested capability, else raise a `ConfigError` li ### `config.missing_fields()` -```python +```python nocheck config.missing_fields(*, need: Capability) -> list[str] ``` @@ -272,7 +274,7 @@ Every required field for `need` that is unset, each with the env var that sets i ### `config.check_endpoints()` -```python +```python nocheck config.check_endpoints(*, need: Capability, code_credential: str | None = None) -> None ``` @@ -284,7 +286,7 @@ The framework-free proxy client factory. ### `donkey.llm.close()` -```python +```python nocheck donkey.llm.close() -> None ``` @@ -292,7 +294,7 @@ Close the blocking client built by this standalone factory, if any. ### `donkey.llm.aclose()` -```python +```python nocheck async donkey.llm.aclose() -> None ``` @@ -300,7 +302,7 @@ Close this factory's owned blocking client in an async scope. ### `donkey.llm.client()` -```python +```python nocheck donkey.llm.client(*, sync: bool = False, **kw: Any) -> AsyncOpenAI | OpenAI ``` @@ -308,7 +310,7 @@ An OpenAI client pointed at the LLM proxy, using our shared http client so heade ### `donkey.llm.list_models()` -```python +```python nocheck async donkey.llm.list_models(*, live: bool = False) -> list[ModelHandle] ``` @@ -316,7 +318,7 @@ List logical models the proxy exposes. ### `donkey.llm.resolve()` -```python +```python nocheck donkey.llm.resolve(model_id: str, *, provider: str | None = None) -> ModelHandle ``` @@ -328,7 +330,7 @@ The token-budget window for one `Donkey`, updated in-band from each response's b ### `donkey.budget.observe()` -```python +```python nocheck donkey.budget.observe(response: httpx.Response, *, now: datetime | None = None) -> None ``` @@ -336,7 +338,7 @@ Update from a response's budget headers. ### `donkey.budget.pace()` -```python +```python nocheck donkey.budget.pace( *, reserve: float = 0.0, @@ -348,7 +350,7 @@ Guard a request so it is refused *before* it crosses your reserve, not after a 4 ### `donkey.budget.wait_for_reset()` -```python +```python nocheck async donkey.budget.wait_for_reset( *, window: str | None = None, @@ -366,7 +368,7 @@ Its fields are listed in [`last_call` fields](https://docs.donkey-kit.dev/refere ### `donkey.last_call.observed` -```python +```python nocheck donkey.last_call.observed: bool ``` @@ -374,7 +376,7 @@ True iff a governed response actually populated this record. ### `donkey.last_call.substituted` -```python +```python nocheck donkey.last_call.substituted: bool ``` @@ -382,7 +384,7 @@ True iff the gateway served a *different* model than the one requested — a sil ### `donkey.last_call.cache_hit` -```python +```python nocheck donkey.last_call.cache_hit: bool ``` @@ -390,7 +392,7 @@ True iff the gateway served this call from its semantic cache (`cache_status` == ### `donkey.last_call.available` -```python +```python nocheck donkey.last_call.available: bool ``` @@ -398,7 +400,7 @@ False only when the current surface structurally cannot be observed (the framewo ### `LastCall.from_response()` -```python +```python nocheck LastCall.from_response( response: httpx.Response, *, diff --git a/website/public/reference/configuration.md b/website/public/reference/configuration.md index 2b58e2cd..56b14075 100644 --- a/website/public/reference/configuration.md +++ b/website/public/reference/configuration.md @@ -1,5 +1,7 @@ # Configuration +Status: Live. Shipped, and verified against a real governed gateway. + `Donkey.from_env(...)` builds its configuration from the values you pass, then environment variables, then the config files, then defaults. `Donkey(DonkeyConfig(...))` uses only what you pass. See [Building a config](#building-a-config) for both paths, diff --git a/website/public/reference/last-call.md b/website/public/reference/last-call.md index 187ed2a0..c4195125 100644 --- a/website/public/reference/last-call.md +++ b/website/public/reference/last-call.md @@ -1,5 +1,7 @@ # `last_call` field reference +Status: Live. Shipped, and verified against a real governed gateway. + `donkey.last_call` is what the gateway said about the **most recent governed model call** in the current context. It is an immutable snapshot: every governed response replaces the record wholesale rather than mutating it, so a reader diff --git a/website/public/reference/unsupported-boundary.md b/website/public/reference/unsupported-boundary.md index e1c9e780..08523a0a 100644 --- a/website/public/reference/unsupported-boundary.md +++ b/website/public/reference/unsupported-boundary.md @@ -1,5 +1,7 @@ # Unsupported boundary +Status: Live. Shipped, and verified against a real governed gateway. + Which platform APIs does the SDK call, and are they supported for third-party use? This page answers that question for security and procurement reviews. diff --git a/website/public/scenarios.md b/website/public/scenarios.md index 2714d823..08e97d44 100644 --- a/website/public/scenarios.md +++ b/website/public/scenarios.md @@ -1,5 +1,7 @@ # Scenarios +Status: Live. Shipped, and verified against a real governed gateway. + The feature pages tell you what each piece *is*. These pages show three of them working together on a real job, start to finish — the same three scenarios the build guide uses to justify the skeleton (`BG §1.8`). Each one is runnable diff --git a/website/public/scenarios/internal-copilot.md b/website/public/scenarios/internal-copilot.md index 381c7d79..25300291 100644 --- a/website/public/scenarios/internal-copilot.md +++ b/website/public/scenarios/internal-copilot.md @@ -1,5 +1,7 @@ # Internal copilot +Status: Live. Shipped, and verified against a real governed gateway. + An internal copilot answers employee questions against company systems. Its output has to clear a **content-safety guardrail** before it reaches a person, and when the guardrail fires you need the refusal to (a) surface as something diff --git a/website/public/scenarios/nightly-batch.md b/website/public/scenarios/nightly-batch.md index 41ed20ec..7b0f03c2 100644 --- a/website/public/scenarios/nightly-batch.md +++ b/website/public/scenarios/nightly-batch.md @@ -1,5 +1,7 @@ # Nightly batch +Status: Live. Shipped, and verified against a real governed gateway. + 50,000 product records, enriched overnight against a governed model, budget window resetting every hour, no human awake. Without a budget object the script runs flat out, takes a `429` partway through, crashes, and someone re-runs it diff --git a/website/public/scenarios/support-triage.md b/website/public/scenarios/support-triage.md index b0668645..37f5ab1d 100644 --- a/website/public/scenarios/support-triage.md +++ b/website/public/scenarios/support-triage.md @@ -1,5 +1,7 @@ # Support triage +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. + A support agent drafts a one-line reply to each ticket in a queue. Most are routine; one contains a customer's SSN. On a bare `base_url` that PII-laden call sails straight through to the model. Through a governed proxy it comes back as a diff --git a/website/public/simulator.md b/website/public/simulator.md index 6c1e5697..c638f418 100644 --- a/website/public/simulator.md +++ b/website/public/simulator.md @@ -1,6 +1,6 @@ # Local simulator -Live +Status: Live. Shipped, and verified against a real governed gateway. Nobody can make the production gateway emit a PII block on cue, so the branch of your agent that handles `PIIDetected` usually runs for the first time on a diff --git a/website/public/telemetry.md b/website/public/telemetry.md index b44a0227..c1df0e47 100644 --- a/website/public/telemetry.md +++ b/website/public/telemetry.md @@ -1,6 +1,6 @@ # Telemetry & cost -Live +Status: Live. Shipped, and verified against a real governed gateway. DDK answers two questions about every governed call: *what happened?* and *who pays for it?* It does so with OpenTelemetry GenAI spans, per-run correlation diff --git a/website/public/testing.md b/website/public/testing.md index 7cd3f374..24b90ae1 100644 --- a/website/public/testing.md +++ b/website/public/testing.md @@ -1,6 +1,6 @@ # Testing & conformance -Live +Status: Live. Shipped, and verified against a real governed gateway. Three test-time tools, all serving the same captured gateway rejections: diff --git a/website/public/tool-access.md b/website/public/tool-access.md index 87c75c01..36bf7810 100644 --- a/website/public/tool-access.md +++ b/website/public/tool-access.md @@ -1,6 +1,6 @@ # Tool access -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/tool-access/a2a.md b/website/public/tool-access/a2a.md index e7383fd3..393e6aae 100644 --- a/website/public/tool-access/a2a.md +++ b/website/public/tool-access/a2a.md @@ -1,6 +1,6 @@ # A2A agent tools -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/tool-access/binding.md b/website/public/tool-access/binding.md index 533ec60f..a67a3e6d 100644 --- a/website/public/tool-access/binding.md +++ b/website/public/tool-access/binding.md @@ -1,6 +1,6 @@ # Framework binding -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/tool-access/discovery.md b/website/public/tool-access/discovery.md index 9b7d38fd..9467c865 100644 --- a/website/public/tool-access/discovery.md +++ b/website/public/tool-access/discovery.md @@ -1,6 +1,6 @@ # Discovery, search & filter -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/tool-access/lockfile.md b/website/public/tool-access/lockfile.md index 57b780d4..30aa6c8c 100644 --- a/website/public/tool-access/lockfile.md +++ b/website/public/tool-access/lockfile.md @@ -1,6 +1,6 @@ # Pinning & lockfile -Roadmap +Status: Roadmap. Planned design; not shipped yet. This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. diff --git a/website/public/troubleshooting.md b/website/public/troubleshooting.md index 51aec77d..10e1012d 100644 --- a/website/public/troubleshooting.md +++ b/website/public/troubleshooting.md @@ -1,5 +1,7 @@ # Troubleshooting +Status: Live. Shipped, and verified against a real governed gateway. + Start with `donkey doctor`. It makes one real governed call and tells apart the failures that look the same from your code. The sections below explain each line it prints, then cover the problems `doctor` can't see. diff --git a/website/public/use-with-your-agent.md b/website/public/use-with-your-agent.md index 1d3442bf..ebee3776 100644 --- a/website/public/use-with-your-agent.md +++ b/website/public/use-with-your-agent.md @@ -1,6 +1,6 @@ # Use these docs with your agent -Live +Status: Live. Shipped, and verified against a real governed gateway. You probably ask your coding assistant before you open a docs site. These docs are published in the [llms.txt convention](https://llmstxt.org) so an assistant diff --git a/website/scripts/generate-llms.mjs b/website/scripts/generate-llms.mjs index fa022f79..670f3f90 100644 --- a/website/scripts/generate-llms.mjs +++ b/website/scripts/generate-llms.mjs @@ -6,6 +6,10 @@ // // Source of truth is the pages themselves: ordering + titles come from the // `_meta.js` tree, content + description from each `.mdx` and its frontmatter. +// The frontmatter `status:` (lib/status.mjs, #797) is shown in each llms.txt +// entry and under the H1 of each per-page .md, as the rendered badge is on the +// site (and, like the badge, left out for `statusBadge: false` pages); a page +// with a missing or unknown status fails the run. // This script owns NO copy of the content — it only transforms what already // ships on the site, so it can never invent an endpoint/header/class name the // pages don't already document (verification discipline). CI regenerates and fails on any diff @@ -17,6 +21,8 @@ import { readFile, writeFile, mkdir, access } from 'node:fs/promises' import { dirname, join, resolve, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import { statusOf } from '../lib/status.mjs' + const HERE = dirname(fileURLToPath(import.meta.url)) const WEBSITE = resolve(HERE, '..') const CONTENT = join(WEBSITE, 'content') @@ -188,6 +194,15 @@ async function transformFile(file, pageSet, seen = new Set()) { return `${rendered.replace(/\n{3,}/g, '\n\n').trim()}\n` } +// The .md twin of the badge page.tsx renders: a status line right under the H1. +function withStatusLine(markdown, status) { + if (!status.badge) return markdown + const line = `Status: ${status.label}. ${status.summary}` + return /^# [^\n]*\n/.test(markdown) + ? markdown.replace(/^(# [^\n]*\n)/, `$1\n${line}\n`) + : `${line}\n\n${markdown}` +} + function firstParagraph(markdown) { for (const block of markdown.split('\n\n')) { const t = block.trim() @@ -213,7 +228,8 @@ async function main() { throw new Error(`_meta.js references a missing page: ${relative(WEBSITE, page.file)}`) } const { frontmatter } = stripFrontmatter(await readFile(page.file, 'utf8')) - page.markdown = await transformFile(page.file, pageSet) + page.status = statusOf(frontmatter, relative(WEBSITE, page.file)) + page.markdown = withStatusLine(await transformFile(page.file, pageSet), page.status) page.description = frontmatter.description || truncate(firstParagraph(page.markdown), 200) page.url = `${SITE}/${page.urlPath || 'index'}` page.mdUrl = `${page.url}.md` @@ -235,7 +251,11 @@ async function main() { section = page.section indexLines.push('', `## ${section}`, '') } - const desc = page.description ? `: ${page.description}` : '' + // `statusBadge: false` pages document no SDK surface, so they carry no + // status here either, as withStatusLine leaves their .md alone. + const parts = [page.status.badge && `(status: ${page.status.status})`, page.description] + const text = parts.filter(Boolean).join(' ') + const desc = text ? `: ${text}` : '' indexLines.push(`- [${page.title}](${page.mdUrl})${desc}`) } indexLines.push('') diff --git a/website/styles/globals.css b/website/styles/globals.css index 6a341f43..3eb83574 100644 --- a/website/styles/globals.css +++ b/website/styles/globals.css @@ -629,6 +629,12 @@ a.af-card:hover .af-card-title::after { border-color: var(--af-accent-border); } +/* The page-status badge (lib/status.mjs), rendered from frontmatter above the + H1 by app/[[...mdxPath]]/page.tsx. */ +.af-page-status { + margin: 0 0 0.5rem; +} + .af-card-badges { display: flex; flex-wrap: wrap; From 5e1c4d8ee32802547245e60cc8860fe253b57050 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 11 Oct 2026 12:24:49 +0200 Subject: [PATCH 11/34] ci(site): build, type-check and link-check the docs site on PRs (#1076) Closes #759 --- .github/workflows/ci.yml | 65 ++++++++++++++++- .github/workflows/docs.yml | 2 +- docs/ci.md | 25 ++++--- website/README.md | 14 ++++ website/package-lock.json | 11 +-- website/package.json | 9 ++- website/scripts/check-links.mjs | 119 ++++++++++++++++++++++++++++++++ website/scripts/lint.mjs | 28 ++++++++ 8 files changed, 256 insertions(+), 17 deletions(-) create mode 100644 website/scripts/check-links.mjs create mode 100644 website/scripts/lint.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7c3388b..67f4dc88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -808,7 +808,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 20 + node-version: 24 - name: Unit-test the analytics beacon gate and the reader-facing link ref run: npm test - run: node scripts/generate-llms.mjs @@ -822,6 +822,68 @@ jobs: fi echo "llms artifacts are up to date." + docs-site: + # #759: build, type-check and link-check the Nextra site on PRs, so broken + # MDX, a bad import or a dead internal link fails here instead of in the + # release deploy (docs.yml builds only from main). Job-level path filter: + # a workflow-level `paths:` would skip the required `ci-ok` aggregate, so + # the job always starts and its steps no-op when the PR leaves the site + # alone. On pushes to main/develop it always runs. + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + defaults: + run: + working-directory: website + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Two commits, so HEAD^1 (the base tip of a PR merge commit) exists. + fetch-depth: 2 + - name: does this change touch the site? + id: scope + working-directory: . + run: | + if [ "$GITHUB_EVENT_NAME" != "pull_request" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git diff --name-only HEAD^1 HEAD | grep -qE '^(website/|\.github/workflows/(ci|docs)\.yml$)'; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "No website/** or docs workflow changes; skipping the site checks." + fi + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + if: steps.scope.outputs.run == 'true' + with: + # No `cache: npm`: setup-* built-in caching saves from PRs too, which + # #1005 rules out for ci.yml. The install is a few seconds. + node-version: 24 + - if: steps.scope.outputs.run == 'true' + run: npm ci + - name: Type-check + if: steps.scope.outputs.run == 'true' + run: npm run typecheck + - name: Lint + if: steps.scope.outputs.run == 'true' + run: npm run lint + - name: Build the static export (project-prefix base path, as preview:pages does) + if: steps.scope.outputs.run == 'true' + # prebuild refreshes data/roadmap.json from the GitHub API and keeps + # the committed snapshot if the API is unreachable. + run: npm run build + env: + DOCS_BASE_PATH: /donkey-development-kit + GITHUB_TOKEN: ${{ github.token }} + - name: Check internal links and anchors in out/ + if: steps.scope.outputs.run == 'true' + run: npm run check:links + env: + DOCS_BASE_PATH: /donkey-development-kit + pip-audit: # #757: known vulnerabilities in what a consumer installs today, blocking. # The install is resolved fresh (no -c): the question is what @@ -952,6 +1014,7 @@ jobs: - quickstart - langgraph-demo - docs-llms-drift + - docs-site - pip-audit - dependency-review - workflow-lint diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5f252aa0..f6819092 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -50,7 +50,7 @@ jobs: ref: main - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 20 + node-version: 24 cache: npm cache-dependency-path: website/package-lock.json - run: npm ci diff --git a/docs/ci.md b/docs/ci.md index f966c1fe..2e5540e8 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -33,7 +33,7 @@ policy behind the locks and the nightly run, see - [5. One task runner mirrors CI. Target (#765)](#5-one-task-runner-mirrors-ci-target-765) - [6. Jobs are bounded and superseded runs are cancelled. In place (#761)](#6-jobs-are-bounded-and-superseded-runs-are-cancelled-in-place-761) - [7. Least-privilege workflows. Partly in place (#757, #771)](#7-least-privilege-workflows-partly-in-place-757-771) - - [8. The site is built on PRs, on a supported Node. Target (#759)](#8-the-site-is-built-on-prs-on-a-supported-node-target-759) + - [8. The site is built on PRs, on a supported Node. In place (#759)](#8-the-site-is-built-on-prs-on-a-supported-node-in-place-759) - [9. Release gates are code, not prose. Target (#767)](#9-release-gates-are-code-not-prose-target-767) - [10. Workflow names follow one style. In place (#1009)](#10-workflow-names-follow-one-style-in-place-1009) - [Changing a workflow](#changing-a-workflow) @@ -79,7 +79,8 @@ The jobs, grouped by what they protect: - **Promises made in the docs.** `benchmark` enforces the < 1 ms span overhead (BG §1.6) and trends it across runs. `quickstart` and `langgraph-demo` run the documented examples against the local simulator. `docs-llms-drift` - regenerates `llms.txt` and fails on any diff. + regenerates `llms.txt` and fails on any diff. `docs-site` builds the site + and checks its internal links and anchors (#759). - **Supply chain.** `pip-audit` audits a fresh resolve of the base install and of `.[all]` for known vulnerabilities, blocking (#757). `dependency-review` flags a PR that adds a vulnerable dependency or one @@ -418,13 +419,19 @@ Still open: workflows) with the toolchain lock (#763). - Pin manual docs deploys to `main` (#771). -### 8. The site is built on PRs, on a supported Node. **Target** (#759) - -A PR job, filtered to `website/**`, runs `npm ci`, a production build, a link -and anchor check over the export, and `tsc --noEmit`. Today the only website -job on PRs is `docs-llms-drift`, so the site is first built after promotion to -`main`. Both workflows still use Node 20, which reached end of life on -2026-04-30. +### 8. The site is built on PRs, on a supported Node. **In place** (#759) + +The `docs-site` job in `ci.yml` builds the site on every PR that touches it, +so broken MDX, a bad import or a dead internal link fails there instead of in +the release deploy. It runs `npm ci`, `npm run typecheck`, `npm run lint`, a +production build with `DOCS_BASE_PATH=/donkey-development-kit` (the project +prefix the Pages deploy uses), and `npm run check:links` over `out/`. The +check is a job-level path test on `website/**` and the two docs workflows, not +a workflow-level `paths:` filter: a skipped workflow would never report +`ci-ok`, the required check (convention 2). The job always starts and its steps +no-op when the PR leaves the site alone. Pushes to `main` and `develop` always +run it. `ci.yml` and `docs.yml` both use Node 24, since Node 20 reached end of +life on 2026-04-30. ### 9. Release gates are code, not prose. **Partly in place** (#767) diff --git a/website/README.md b/website/README.md index f3c47b17..acb44d8f 100644 --- a/website/README.md +++ b/website/README.md @@ -134,6 +134,20 @@ errors. `npm test` runs the beacon gate's unit tests. +## Checks (same as the `docs-site` CI job) + +Needs Node >= 22 (CI uses 24). Run after `npm ci`: + +```bash +npm run typecheck # tsc --noEmit +npm run lint # syntax-check the .mjs files (scripts/lint.mjs) +DOCS_BASE_PATH=/donkey-development-kit npm run build +DOCS_BASE_PATH=/donkey-development-kit npm run check:links # internal links + #anchors in out/ +``` + +There is no `start` script: the site is a static export (`output: 'export'`), +so serve `out/` with `npm run preview:pages` instead of `next start`. + ## Structure The sidebar is grouped into capability pillars by separators in diff --git a/website/package-lock.json b/website/package-lock.json index 861be04e..f645c601 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -15,10 +15,13 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "@types/node": "^20.14.0", + "@types/node": "^22.20.5", "@types/react": "^19.0.0", "pagefind": "^1.5.2", "typescript": "^5.5.3" + }, + "engines": { + "node": ">=22" } }, "node_modules/@antfu/install-pkg": { @@ -1858,9 +1861,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/website/package.json b/website/package.json index 269e0c4d..18964f7c 100644 --- a/website/package.json +++ b/website/package.json @@ -12,7 +12,9 @@ "postbuild": "pagefind --site out --output-subdir _pagefind", "preview:pages": "node scripts/preview-pages.mjs", "test": "node --test lib/analytics.test.mjs lib/repo.test.mjs lib/site.test.mjs lib/status.test.mjs lib/version.test.mjs", - "start": "next start -p ${PORT:-3000}" + "lint": "node scripts/lint.mjs", + "typecheck": "tsc --noEmit", + "check:links": "node scripts/check-links.mjs" }, "dependencies": { "next": "^16.3.8", @@ -22,7 +24,7 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "@types/node": "^20.14.0", + "@types/node": "^22.20.5", "@types/react": "^19.0.0", "pagefind": "^1.5.2", "typescript": "^5.5.3" @@ -30,5 +32,8 @@ "overrides": { "zod": "4.3.6", "@xmldom/xmldom": "0.9.12" + }, + "engines": { + "node": ">=22" } } diff --git a/website/scripts/check-links.mjs b/website/scripts/check-links.mjs new file mode 100644 index 00000000..48e16d03 --- /dev/null +++ b/website/scripts/check-links.mjs @@ -0,0 +1,119 @@ +// Link and anchor check over the static export (`out/`), no dependencies. +// +// Every internal , ,