From b2324c5a04623931142b77967c0dee54adb390f8 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Tue, 22 Sep 2026 10:47:44 +0200 Subject: [PATCH 1/5] fix(adapters): pass verified model= kwarg to agent_framework OpenAIChatClient (#521) Closes #520 --- docs/verified-apis.md | 2 +- .../integrations/agent_framework.py | 49 +++++++++++++------ python/tests/unit/test_adapter_ergonomics.py | 43 ++++++++++++++++ .../content/frameworks/agent-framework.mdx | 20 ++++---- website/public/frameworks/agent-framework.md | 20 ++++---- website/public/llms-full.txt | 20 ++++---- 6 files changed, 107 insertions(+), 47 deletions(-) diff --git a/docs/verified-apis.md b/docs/verified-apis.md index 230c7adb..d094bba8 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -390,7 +390,7 @@ provisioning API. Exact REST calls behind the CLI are now recorded in §12 |---|---|---|---|---|---| | LangGraph | `langchain_openai.ChatOpenAI(base_url, api_key, default_headers, http_async_client, use_responses_api=True)` | UNVERIFIED (constructor); endpoint VERIFIED | The kwargs/class name are still §8-pending, but the deep adapter (#198) pins `use_responses_api=True` so it calls the **live-verified** `/responses` data plane (§4) rather than ChatOpenAI's default `/chat/completions` route. Overridable via `chat_model(..., use_responses_api=False)`. | 2026-09-11 | #198 | | Google ADK | `google.adk.models.lite_llm.LiteLlm(model="openai/…", api_base, extra_headers)` | UNVERIFIED | — | — | — | -| MS Agent Framework | `agent_framework.openai.OpenAIChatClient` name + `model_id` kwarg | UNVERIFIED | — | — | — | +| MS Agent Framework | `agent_framework.openai.OpenAIChatClient(model, base_url, api_key, default_headers)` | VERIFIED | Class path confirmed; the constructor kwarg is `model=` — `model_id` is **not** accepted. `base_url`/`api_key`/`default_headers` all accepted, so `connection_kwargs()` is unchanged. Pinned to agent-framework 1.19.0. | 2026-09-22 | #520 (`verify_frameworks.py --only agent_framework`) | | OpenAI Agents SDK | `agents.OpenAIChatCompletionsModel(model, openai_client=AsyncOpenAI(...))` | UNVERIFIED | — | — | — | | Anthropic SDK | `anthropic.AsyncAnthropic(base_url, api_key, default_headers, http_client)` — model id per-call; proxy Anthropic-native route UNVERIFIED | UNVERIFIED | — | — | — | | CrewAI | `crewai.LLM(model="openai/…", base_url, api_key, extra_headers)` | UNVERIFIED | — | — | — | diff --git a/python/src/donkey_kit/integrations/agent_framework.py b/python/src/donkey_kit/integrations/agent_framework.py index 376eb2af..38ef45da 100644 --- a/python/src/donkey_kit/integrations/agent_framework.py +++ b/python/src/donkey_kit/integrations/agent_framework.py @@ -4,8 +4,12 @@ Current Python surface is ``from agent_framework import Agent`` with ``Agent(client=, name=..., instructions=...)``. The -OpenAI-compatible chat client class name and its base-URL kwarg MUST be verified -— this package is young and renamed classes recently (docs/verified-apis.md §8). +OpenAI-compatible chat client class path and its constructor kwargs are +VERIFIED against agent-framework 1.19.0 (docs/verified-apis.md §8): +``agent_framework.openai.OpenAIChatClient`` takes ``model``, ``base_url``, +``api_key`` and ``default_headers``. Both the import and the construction stay +guarded so a future upstream rename surfaces as a ``_verify.blocked(...)`` +refusal, never a raw ``ImportError``/``TypeError`` reaching the caller. Agent Framework has first-class middleware for intercepting agent actions. We ship :meth:`policy_middleware` that catches :class:`PolicyViolation` and @@ -32,31 +36,44 @@ class AgentFrameworkAdapter(Adapter): observes_last_call = False def connection_kwargs(self) -> dict[str, Any]: - """Governed kwargs for an ``OpenAIChatClient(model_id=…, **kwargs)`` you - build yourself. NOTE (docs/verified-apis.md §8): the class name/path and its base-URL kwarg - are UNVERIFIED — confirm against the installed version before relying on constructing the - client by hand.""" + """Governed kwargs for an ``OpenAIChatClient(model=…, **kwargs)`` you + build yourself. VERIFIED against agent-framework 1.19.0 + (docs/verified-apis.md §8): ``base_url``/``api_key``/``default_headers`` + are all accepted by the constructor.""" return self._openai_connection() # base_url, api_key, default_headers def chat_client(self, model: str, **kw: Any) -> Any: self._require_proxy() try: from agent_framework.openai import ( - OpenAIChatClient, # VERIFY name/path: docs/verified-apis.md §8 + OpenAIChatClient, # verified: docs/verified-apis.md §8 (1.19.0) ) except ImportError as exc: raise _verify.blocked( - "agent_framework OpenAI chat client class name/path " - "(docs/verified-apis.md §8). The package renamed classes recently; " - "confirm 'agent_framework.openai.OpenAIChatClient' and its base-URL " - "kwarg against the installed version before relying on this adapter." + "agent_framework.openai.OpenAIChatClient import " + "(docs/verified-apis.md §8). The class path is VERIFIED against " + "agent-framework 1.19.0; an ImportError here means the package is " + "absent or has renamed the class again. Install 'agent-framework' " + "or confirm the class path against your installed version." ) from exc - return OpenAIChatClient( - model_id=model, # VERIFY kwarg name: docs/verified-apis.md §8 - **self.connection_kwargs(), - **kw, - ) + try: + return OpenAIChatClient( + model=model, # verified: docs/verified-apis.md §8 (1.19.0) + **self.connection_kwargs(), + **kw, + ) + except TypeError as exc: + # A TypeError from the constructor means a kwarg this adapter relies on + # was renamed upstream. Surface it as a verification refusal, not a raw + # TypeError leaking out of the SDK (§0.3, BG §1.8). + raise _verify.blocked( + "agent_framework.openai.OpenAIChatClient constructor signature " + "(docs/verified-apis.md §8). Verified against agent-framework 1.19.0 " + "(model=, base_url=, api_key=, default_headers=); a TypeError here " + "means the installed version renamed a kwarg. Confirm the signature " + "against your installed version and update the adapter." + ) from exc def policy_middleware(self) -> Callable[..., Any]: """Middleware that converts a :class:`PolicyViolation` into a clean, diff --git a/python/tests/unit/test_adapter_ergonomics.py b/python/tests/unit/test_adapter_ergonomics.py index 44da72a4..938e8350 100644 --- a/python/tests/unit/test_adapter_ergonomics.py +++ b/python/tests/unit/test_adapter_ergonomics.py @@ -284,6 +284,49 @@ def fail_openai_import(name: str, *args: Any, **kwargs: Any) -> Any: assert exc_info.value.__cause__ is import_error +def test_agent_framework_chat_client_constructs_with_package_present() -> None: + """The mirror of the blocks-on-import test (#520): with agent-framework + actually installed, the factory returns a real ``OpenAIChatClient`` built + with the VERIFIED ``model=`` kwarg — the path the acceptance harness hit + that the package-absent test never exercised. VERIFIED: agent-framework + 1.19.0 (docs/verified-apis.md §8).""" + pytest.importorskip("agent_framework") + from agent_framework.openai import OpenAIChatClient + + from donkey_kit.integrations.agent_framework import AgentFrameworkAdapter + + client = AgentFrameworkAdapter(_cfg(), _http()).chat_client("gpt-4o") + assert isinstance(client, OpenAIChatClient) + + +def test_agent_framework_chat_client_blocks_on_constructor_rename( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A future upstream kwarg rename must surface as a ``_verify.blocked(...)`` + refusal, not the raw ``TypeError`` that reached callers in 0.1.0.dev4 (#520, + §0.3). Stub ``OpenAIChatClient`` with a constructor that rejects ``model=`` + (as a rename would); the widened guard turns the resulting ``TypeError`` + into a verification-blocked error. Runs without the package installed.""" + from donkey_kit.integrations.agent_framework import AgentFrameworkAdapter + + class _RenamedChatClient: + def __init__(self, *, renamed_model: str, **_kw: Any) -> None: # no ``model`` + self.renamed_model = renamed_model + + for name in ("agent_framework", "agent_framework.openai"): + if name not in sys.modules: + monkeypatch.setitem(sys.modules, name, types.ModuleType(name)) + pkg, openai_mod = sys.modules["agent_framework"], sys.modules["agent_framework.openai"] + monkeypatch.setattr(pkg, "openai", openai_mod, raising=False) + monkeypatch.setattr(openai_mod, "OpenAIChatClient", _RenamedChatClient, raising=False) + + adapter = AgentFrameworkAdapter(_cfg(), _http()) + with pytest.raises(NotImplementedError, match=r"^blocked on verification:") as exc_info: + adapter.chat_client("gpt-4o") + + assert isinstance(exc_info.value.__cause__, TypeError) + + # --- The two paths cannot drift (issue #33 AC) ------------------------------ # # Each framework exposes the SAME governed native object two ways: (a) the diff --git a/website/content/frameworks/agent-framework.mdx b/website/content/frameworks/agent-framework.mdx index 10e47ba5..b45e2f22 100644 --- a/website/content/frameworks/agent-framework.mdx +++ b/website/content/frameworks/agent-framework.mdx @@ -77,7 +77,7 @@ from agent_framework.openai import OpenAIChatClient async with Donkey.from_env() as donkey: llm = OpenAIChatClient( - model_id="gpt-4o", + model="gpt-4o", **donkey.agent_framework.connection_kwargs(), ) ``` @@ -88,7 +88,7 @@ async with Donkey.from_env() as donkey: from agent_framework.openai import OpenAIChatClient llm = OpenAIChatClient( - model_id=..., # UNVERIFIED kwarg name — confirm against your install + model=..., # verified against agent-framework 1.19.0 base_url=..., # from DONKEY_LLM_PROXY_URL, no /v1 suffix api_key=..., default_headers=..., # client_id / client_secret header pair @@ -97,14 +97,14 @@ llm = OpenAIChatClient( ## Notes & limitations -> **UNVERIFIED.** `agent_framework.openai.OpenAIChatClient` and its -> base-URL kwarg have not been confirmed against a live install — the package -> is young and has renamed classes recently. The `chat_client()` factory -> guards this: if the import fails, it raises -> `NotImplementedError("blocked on verification")` instead of silently -> falling back to a wrong class name. Confirm the class path yourself against -> your installed `agent-framework` version before relying on this adapter in -> production. +> **VERIFIED** against agent-framework 1.19.0. +> `agent_framework.openai.OpenAIChatClient` takes `model`, `base_url`, +> `api_key` and `default_headers` (`model_id` is **not** accepted). The +> `chat_client()` factory stays guarded on both ends: if the import fails or +> the constructor signature changes upstream, it raises +> `NotImplementedError("blocked on verification")` instead of leaking a raw +> `ImportError`/`TypeError` — so a future upstream rename surfaces as a clear +> verification refusal rather than a cryptic error. > Agent Framework has first-class middleware for intercepting agent actions. > The adapter ships `policy_middleware()`, which catches a `PolicyViolation` diff --git a/website/public/frameworks/agent-framework.md b/website/public/frameworks/agent-framework.md index dba8b4df..f7780c26 100644 --- a/website/public/frameworks/agent-framework.md +++ b/website/public/frameworks/agent-framework.md @@ -82,7 +82,7 @@ from agent_framework.openai import OpenAIChatClient async with Donkey.from_env() as donkey: llm = OpenAIChatClient( - model_id="gpt-4o", + model="gpt-4o", **donkey.agent_framework.connection_kwargs(), ) ``` @@ -93,7 +93,7 @@ async with Donkey.from_env() as donkey: from agent_framework.openai import OpenAIChatClient llm = OpenAIChatClient( - model_id=..., # UNVERIFIED kwarg name — confirm against your install + model=..., # verified against agent-framework 1.19.0 base_url=..., # from DONKEY_LLM_PROXY_URL, no /v1 suffix api_key=..., default_headers=..., # client_id / client_secret header pair @@ -102,14 +102,14 @@ llm = OpenAIChatClient( ## Notes & limitations -> **UNVERIFIED.** `agent_framework.openai.OpenAIChatClient` and its -> base-URL kwarg have not been confirmed against a live install — the package -> is young and has renamed classes recently. The `chat_client()` factory -> guards this: if the import fails, it raises -> `NotImplementedError("blocked on verification")` instead of silently -> falling back to a wrong class name. Confirm the class path yourself against -> your installed `agent-framework` version before relying on this adapter in -> production. +> **VERIFIED** against agent-framework 1.19.0. +> `agent_framework.openai.OpenAIChatClient` takes `model`, `base_url`, +> `api_key` and `default_headers` (`model_id` is **not** accepted). The +> `chat_client()` factory stays guarded on both ends: if the import fails or +> the constructor signature changes upstream, it raises +> `NotImplementedError("blocked on verification")` instead of leaking a raw +> `ImportError`/`TypeError` — so a future upstream rename surfaces as a clear +> verification refusal rather than a cryptic error. > Agent Framework has first-class middleware for intercepting agent actions. > The adapter ships `policy_middleware()`, which catches a `PolicyViolation` diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index ffc6741a..6259c888 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -1359,7 +1359,7 @@ from agent_framework.openai import OpenAIChatClient async with Donkey.from_env() as donkey: llm = OpenAIChatClient( - model_id="gpt-4o", + model="gpt-4o", **donkey.agent_framework.connection_kwargs(), ) ``` @@ -1370,7 +1370,7 @@ async with Donkey.from_env() as donkey: from agent_framework.openai import OpenAIChatClient llm = OpenAIChatClient( - model_id=..., # UNVERIFIED kwarg name — confirm against your install + model=..., # verified against agent-framework 1.19.0 base_url=..., # from DONKEY_LLM_PROXY_URL, no /v1 suffix api_key=..., default_headers=..., # client_id / client_secret header pair @@ -1379,14 +1379,14 @@ llm = OpenAIChatClient( ## Notes & limitations -> **UNVERIFIED.** `agent_framework.openai.OpenAIChatClient` and its -> base-URL kwarg have not been confirmed against a live install — the package -> is young and has renamed classes recently. The `chat_client()` factory -> guards this: if the import fails, it raises -> `NotImplementedError("blocked on verification")` instead of silently -> falling back to a wrong class name. Confirm the class path yourself against -> your installed `agent-framework` version before relying on this adapter in -> production. +> **VERIFIED** against agent-framework 1.19.0. +> `agent_framework.openai.OpenAIChatClient` takes `model`, `base_url`, +> `api_key` and `default_headers` (`model_id` is **not** accepted). The +> `chat_client()` factory stays guarded on both ends: if the import fails or +> the constructor signature changes upstream, it raises +> `NotImplementedError("blocked on verification")` instead of leaking a raw +> `ImportError`/`TypeError` — so a future upstream rename surfaces as a clear +> verification refusal rather than a cryptic error. > Agent Framework has first-class middleware for intercepting agent actions. > The adapter ships `policy_middleware()`, which catches a `PolicyViolation` From bb7c4ad8fdb23d952905b80fc4136c3ef9ade1d9 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Tue, 22 Sep 2026 11:06:19 +0200 Subject: [PATCH 2/5] chore: add interactive version-bump + auto-merge script (#523) scripts/bump-version.sh reads the current version from the two lockstep files (python/pyproject.toml, python/src/donkey_kit/__init__.py), offers a PEP 440 pre-release-ladder menu (dev counter / aN / bN / rcN / final), confirms the new version, then cuts a release/bump- branch off develop, edits both files, opens a squash PR into develop, and enables gh auto-merge so it lands on CI green. Supports --dry-run and --yes. Co-authored-by: Amir Khan <86777111+amirkhan-ak-sf@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/bump-version.sh | 271 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100755 scripts/bump-version.sh diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 00000000..b797b0a3 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# +# 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 = "…" +# python/src/donkey_kit/__init__.py __version__ = "…" +# +# 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). +# +# The version scheme is PEP 440, milestone-driven, on the pre-release ladder +# 0.1.0.dev0 < … < 0.1.0a1 < 0.1.0b1 < 0.1.0rc1 < 0.1.0 +# and EVERY promotion advances the version (default: the .devN counter). This +# script only asks; it computes valid next steps and refuses anything not +# strictly greater than the current version. +# +# Merge-time approval: [[ddk-merge-strategy]] forbids autonomous merges. Running +# this script IS that explicit authorization — it interactively confirms the new +# version before it touches anything, and enables auto-merge rather than merging +# 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 +# +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" + +DRY_RUN=0 +ASSUME_YES=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --yes|-y) ASSUME_YES=1 ;; + -h|--help) + grep '^#' "$0" | sed -E 's/^# ?//' | sed -n '2,40p' + exit 0 ;; + *) printf 'error: unknown argument: %s\n' "$arg" >&2; exit 2 ;; + esac +done + +BOLD=$'\033[1m'; RESET=$'\033[0m' +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +info() { printf '\033[36m•\033[0m %s\n' "$*"; } +run() { + # Execute a mutating command, or just print it under --dry-run. + if [ "$DRY_RUN" -eq 1 ]; then + printf '\033[33m[dry-run]\033[0m %s\n' "$*" + else + printf '\033[90m$ %s\033[0m\n' "$*" + "$@" || die "command failed: $*" + fi +} + +# --- 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" + +# --- Preconditions ------------------------------------------------------------ +command -v gh >/dev/null 2>&1 || die "the GitHub CLI (gh) is required; install it and 'gh auth login'" +if [ "$DRY_RUN" -eq 0 ]; then + gh auth status >/dev/null 2>&1 || die "not authenticated with gh; run 'gh auth login'" +fi + +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" +info "current version: ${BOLD}${CURRENT}${RESET}" + +# --- Parse the PEP 440 version ------------------------------------------------ +# Shape: MAJOR.MINOR.PATCH optionally + .devN | aN | bN | rcN +if [[ "$CURRENT" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)((\.dev|a|b|rc)([0-9]+))?$ ]]; then + major=${BASH_REMATCH[1]}; minor=${BASH_REMATCH[2]}; patch=${BASH_REMATCH[3]} + pretype=${BASH_REMATCH[5]} # ".dev" | "a" | "b" | "rc" | "" + prenum=${BASH_REMATCH[6]} # counter | "" +else + die "current version '$CURRENT' is not a recognized PEP 440 form this script can bump; use a custom bump by editing the files, or extend this parser" +fi +base="${major}.${minor}.${patch}" + +# --- Build the menu of valid next versions (all strictly greater) ------------- +labels=(); targets=() +add() { labels+=("$1"); targets+=("$2"); } + +if [ -n "$pretype" ]; then + # On the pre-release ladder. Same-rung bump is the common case (default). + prettytype=${pretype#.} # "dev" | "a" | "b" | "rc" + add "next ${prettytype} pre-release (bump the counter)" "${base}${pretype}$((prenum + 1))" + case "$pretype" in + .dev) rank=0 ;; + a) rank=1 ;; + b) rank=2 ;; + rc) rank=3 ;; + esac + [ "$rank" -lt 1 ] && add "promote to alpha" "${base}a1" + [ "$rank" -lt 2 ] && add "promote to beta" "${base}b1" + [ "$rank" -lt 3 ] && add "promote to release candidate" "${base}rc1" + add "cut the final release ${base}" "${base}" +else + # Currently a final X.Y.Z release. + add "patch / hotfix" "${major}.${minor}.$((patch + 1))" + add "next minor, start dev ladder" "${major}.$((minor + 1)).0.dev0" + add "next minor, final" "${major}.$((minor + 1)).0" +fi +add "custom (type it in)" "__CUSTOM__" + +# --- Present the menu --------------------------------------------------------- +printf '\nWhat kind of bump? (default: 1)\n\n' +for i in "${!labels[@]}"; do + n=$((i + 1)) + t="${targets[$i]}" + if [ "$t" = "__CUSTOM__" ]; then + printf ' %d) %s\n' "$n" "${labels[$i]}" + else + printf ' %d) %-42s → \033[1m%s\033[0m\n' "$n" "${labels[$i]}" "$t" + fi +done +printf '\n' + +choice="" +read -r -p "Choice [1]: " choice +choice="${choice:-1}" +[[ "$choice" =~ ^[0-9]+$ ]] || die "not a number: $choice" +idx=$((choice - 1)) +if [ "$idx" -lt 0 ] || [ "$idx" -ge "${#targets[@]}" ]; then + die "choice out of range: $choice" +fi + +NEW="${targets[$idx]}" +if [ "$NEW" = "__CUSTOM__" ]; then + read -r -p "Enter the new PEP 440 version: " NEW + NEW="$(printf '%s' "$NEW" | tr -d '[:space:]')" + [ -n "$NEW" ] || die "no version entered" + # Sanity-check the shape (same grammar as above). + [[ "$NEW" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)((\.dev|a|b|rc)([0-9]+))?$ ]] \ + || die "'$NEW' is not a PEP 440 form this script recognizes (X.Y.Z[.devN|aN|bN|rcN])" +fi + +# --- Enforce: strictly greater than current (guards tag collisions) ----------- +# Best-effort via packaging.version; skipped gracefully if unavailable. +if command -v python3 >/dev/null 2>&1; then + cmp=$(python3 - "$CURRENT" "$NEW" <<'PY' 2>/dev/null +import sys +try: + from packaging.version import Version + cur, new = Version(sys.argv[1]), Version(sys.argv[2]) + print("gt" if new > cur else ("eq" if new == cur else "lt")) +except Exception: + print("skip") +PY +) + case "$cmp" in + lt) die "new version $NEW is LOWER than current $CURRENT — every bump must move forward" ;; + eq) die "new version $NEW equals current $CURRENT — a bump must advance the version (a reused version collides the release tag)" ;; + gt) : ;; + *) info "note: could not verify ordering (packaging not importable); trusting your input" ;; + esac +fi + +# --- Confirm ------------------------------------------------------------------ +BRANCH="release/bump-${NEW}" +printf '\n' +printf ' version: \033[1m%s\033[0m → \033[1m%s\033[0m\n' "$CURRENT" "$NEW" +printf ' branch: %s (off %s)\n' "$BRANCH" "$BASE_BRANCH" +printf ' files: %s, %s\n' "$PYPROJECT" "$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' + +if [ "$ASSUME_YES" -eq 0 ]; then + reply="" + read -r -p "Proceed? [y/N]: " reply + case "$reply" in + y|Y|yes|YES) : ;; + *) info "aborted; nothing changed"; exit 0 ;; + esac +fi + +# --- Cut the branch off the latest develop ------------------------------------ +info "fetching latest $BASE_BRANCH" +run git fetch origin "$BASE_BRANCH" + +# Refuse to clobber an existing branch of the same name. +if git show-ref --verify --quiet "refs/heads/${BRANCH}"; then + die "local branch $BRANCH already exists — delete it or pick a different version" +fi +run git switch -c "$BRANCH" "origin/${BASE_BRANCH}" + +# --- Edit the two files in lockstep (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" +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) + 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" +fi + +# --- Commit + push ------------------------------------------------------------ +COMMIT_MSG="chore: bump version to ${NEW}" +run git add "$PYPROJECT" "$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}\`. + +Every promotion to \`main\` advances the version; this lands the bump on \`${BASE_BRANCH}\` ahead of the promotion PR (see the release conventions). + +Auto-merge (squash) is enabled — this will merge once the blocking CI gates pass (base-only, typecheck-and-lint incl. mypy --strict + lint-imports, and the test matrix)." + +if [ "$DRY_RUN" -eq 1 ]; then + printf '\033[33m[dry-run]\033[0m would run: gh pr create --repo %s --base %s --head %s --title %q --body <…>\n' \ + "$REPO" "$BASE_BRANCH" "$BRANCH" "$PR_TITLE" + printf '\033[33m[dry-run]\033[0m would run: gh pr merge --repo %s --squash --auto --delete-branch --subject %q --head %s\n' \ + "$REPO" "chore: bump version to ${NEW}" "$BRANCH" + info "dry run complete — no branch, commit, or PR was created" + exit 0 +fi + +info "opening PR into $BASE_BRANCH" +gh pr create --repo "$REPO" \ + --base "$BASE_BRANCH" --head "$BRANCH" \ + --title "$PR_TITLE" --body "$PR_BODY" \ + || die "gh pr create failed" + +PR_NUM=$(gh pr view "$BRANCH" --repo "$REPO" --json number --jq .number) \ + || die "could not resolve the PR number just created" +info "opened PR #$PR_NUM" + +# --- Enable auto-merge (squash) — merges the moment CI goes green ------------- +info "enabling auto-merge (squash) on PR #$PR_NUM" +if gh pr merge "$PR_NUM" --repo "$REPO" \ + --squash --auto --delete-branch \ + --subject "chore: bump version to ${NEW} (#${PR_NUM})"; then + printf '\n\033[32m✓\033[0m PR #%s opened with auto-merge enabled.\n' "$PR_NUM" + printf ' It will squash-merge into %s automatically once CI is green.\n' "$BASE_BRANCH" + printf ' Watch it: gh pr checks %s --repo %s --watch\n' "$PR_NUM" "$REPO" +else + printf '\n\033[33m!\033[0m PR #%s is open, but enabling auto-merge failed.\n' "$PR_NUM" + printf ' This usually means repository auto-merge is disabled. Once CI is green, merge with:\n' + printf ' gh pr merge %s --repo %s --squash --delete-branch --subject "chore: bump version to %s (#%s)"\n' \ + "$PR_NUM" "$REPO" "$NEW" "$PR_NUM" + exit 1 +fi From 0d980d7d8143c7a012660810c5e1ef424bd7b91f Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Tue, 22 Sep 2026 11:41:09 +0200 Subject: [PATCH 3/5] fix: scope gh auth precondition to github.com in bump-version.sh (#524) `gh auth status` (no host) checks every configured host and exits non-zero if any unrelated one has a stale token (e.g. a broken git.soma.salesforce.com keyring entry), even when github.com is fully authenticated via GH_TOKEN. Scope the check with --hostname github.com. Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/bump-version.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index b797b0a3..c78ab24f 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -69,7 +69,10 @@ cd "$ROOT" || die "cannot cd to repo root: $ROOT" # --- Preconditions ------------------------------------------------------------ command -v gh >/dev/null 2>&1 || die "the GitHub CLI (gh) is required; install it and 'gh auth login'" if [ "$DRY_RUN" -eq 0 ]; then - gh auth status >/dev/null 2>&1 || die "not authenticated with gh; run 'gh auth login'" + # Scope to github.com: `gh auth status` (no host) checks every configured + # host and exits non-zero if any unrelated one has a stale token. + gh auth status --hostname github.com >/dev/null 2>&1 \ + || die "not authenticated to github.com; run 'gh auth login' (a stale token on another host does not count)" fi if [ -n "$(git status --porcelain)" ]; then From 04f456dcbd73a46476b494d2ad050fcac8f8360c Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Tue, 22 Sep 2026 11:43:25 +0200 Subject: [PATCH 4/5] chore: bump version to 0.1.0.dev5 (#527) --- python/pyproject.toml | 2 +- python/src/donkey_kit/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 9ecb576c..c8e30051 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "donkey-kit" -version = "0.1.0.dev4" +version = "0.1.0.dev5" 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" diff --git a/python/src/donkey_kit/__init__.py b/python/src/donkey_kit/__init__.py index cdf3a03b..b16e1a15 100644 --- a/python/src/donkey_kit/__init__.py +++ b/python/src/donkey_kit/__init__.py @@ -52,7 +52,7 @@ PublicationAssetType, ) -__version__ = "0.1.0.dev4" +__version__ = "0.1.0.dev5" __all__ = [ "STRICT", From ee0364d1a5b587ca080aa1b45c876d00ca3f2993 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Tue, 22 Sep 2026 11:56:49 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(verify):=20verify=20inbound=20correla?= =?UTF-8?q?tion=20+=20cost-tag=20request=20headers=20(=C2=A70.3,=20#522)?= =?UTF-8?q?=20(#525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #522 --- docs/verified-apis.md | 49 ++++++++------- python/src/donkey_kit/core/_verify.py | 59 +++++++++++++----- python/src/donkey_kit/core/transport.py | 19 +++--- python/tests/sandbox/test_proxy_contracts.py | 29 +++++++++ python/tests/unit/test_transport.py | 65 ++++++++++++++++++-- website/content/concepts/observability.mdx | 13 ++-- website/content/telemetry.mdx | 27 ++++---- website/public/concepts/observability.md | 13 ++-- website/public/llms-full.txt | 36 ++++++----- website/public/telemetry.md | 23 ++++--- 10 files changed, 236 insertions(+), 97 deletions(-) diff --git a/docs/verified-apis.md b/docs/verified-apis.md index d094bba8..c062733e 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -176,30 +176,35 @@ no `UnverifiedValueWarning`. | Gateway identity on response | `core/transport.py` (`x-llm-proxy-llm-provider` → `gen_ai.system`, #192); `core/lastcall.py` **parses** `x-envoy-decorator-operation` and `x-request-id` (#362) | VERIFIED (LIVE) | `x-envoy-decorator-operation: api-instance-21133858.3e6ce455-…svc`; `x-correlation-id`; `x-llm-proxy-llm-provider/-llm-model/-routing-type` | 2026-08-28 | `responses.success.headers.txt` | | Agent→agent egress attribution header | `core/_verify.py` → transport | VERIFIED (build) | `x-anypoint-api-instance-id` → `agent-connection-telemetry` policy `sourceAgentId`; `tracing` labels `mulesoft.api.instance.id`, `mulesoft.api.type=llm` | 2026-08-28 | built `connection.json` (§12.6) | | Business-group attribution header name | `core/_verify.py` → transport | UNVERIFIED | not surfaced as a request header in the direct-proxy path | — | — | -| Run correlation id **request** header (`X-Correlation-Id`, #195) | `core/_verify.py` `CORRELATION_ID_HEADER` → transport | UNVERIFIED | `x-correlation-id` is verified as a **response echo** (row above); that the gateway **reads** an inbound `X-Correlation-Id` as the run/trace join key is NOT confirmed. Placeholder, overridable via `correlation_header` / `DONKEY_CORRELATION_HEADER`. | — | — | -| Per-call id **request** header (`X-Donkey-Request-Id`, #195) | `core/_verify.py` `CALL_ID_HEADER` → transport | UNVERIFIED | client-generated per logical request, stable across that request's retries; no evidence the gateway reads this name yet. Placeholder, overridable via `call_id_header` / `DONKEY_CALL_ID_HEADER`. | — | — | -| Cost tag: team **request** header (`X-Anypoint-Cost-Team`, #196) | `core/_verify.py` `COST_TEAM_HEADER` → transport | UNVERIFIED | the gateway-side cost-attribution header name is the highest-priority unknown; placeholder, overridable via `cost_team_header` / `DONKEY_COST_TEAM_HEADER`. Value always carried on the `donkey.cost.team` span attribute regardless (SDK owns the span). | — | — | -| Cost tag: project **request** header (`X-Anypoint-Cost-Project`, #196) | `core/_verify.py` `COST_PROJECT_HEADER` → transport | UNVERIFIED | placeholder, overridable via `cost_project_header` / `DONKEY_COST_PROJECT_HEADER`; full value on `donkey.cost.project` span attribute. | — | — | -| Cost tag: env **request** header (`X-Anypoint-Cost-Env`, #196) | `core/_verify.py` `COST_ENV_HEADER` → transport | UNVERIFIED | placeholder, overridable via `cost_env_header` / `DONKEY_COST_ENV_HEADER`; full value on `donkey.cost.env` span attribute. | — | — | -| Cost tag: enduser id **request** header (`X-Anypoint-Cost-Enduser-Id`, #196) | `core/_verify.py` `COST_ENDUSER_HEADER` → transport | UNVERIFIED | placeholder, overridable via `cost_enduser_header` / `DONKEY_COST_ENDUSER_HEADER`; full value on `donkey.cost.enduser.id` span attribute. | — | — | +| Run correlation id **request** header (`X-Correlation-Id`, #195, #522) | `core/_verify.py` `CORRELATION_ID_HEADER` → transport | VERIFIED (LIVE) | the gateway **reads** the inbound `X-Correlation-Id` and echoes it **verbatim** on the response `x-correlation-id` — a probe sending `X-Correlation-Id: ddk522-corr` got `ddk522-corr` back on both the 200 and 400 paths. So this IS the client→gateway run/trace join key. Overridable via `correlation_header` / `DONKEY_CORRELATION_HEADER`. | 2026-09-22 | live probe against `ddk-multi-route-fallback` (instance 21179672, DDK/Sandbox) | +| Per-call id **request** header (`X-Donkey-Request-Id`, #195, #522) | `core/_verify.py` `CALL_ID_HEADER` → transport | VERIFIED (LIVE) | **negative** — the gateway consumes NO inbound per-call-id header (not echoed; absent from all 10 applied system policies). It is a **client-owned** id surfaced on `DonkeyError.call_id`, stable across a request's retries; the gateway need not read it. Name is the SDK's own convention, overridable via `call_id_header` / `DONKEY_CALL_ID_HEADER`. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) | +| Cost tag: team **request** header (`X-Anypoint-Cost-Team`, #196, #522) | `core/_verify.py` `COST_TEAM_HEADER` → transport | VERIFIED (LIVE) | **negative** — the LLM Gateway has NO inbound cost-tag ingestion: the name is not echoed and appears in none of the 10 applied system policies. Cost/usage is collected by the `llm-proxy-core` policy from token usage and attributed by API instance + consuming client application, never by a client header. The authoritative carrier is the `donkey.cost.team` span attribute (SDK owns the span). The `X-Anypoint-Cost-Team` name is a forward-looking, overridable (`cost_team_header` / `DONKEY_COST_TEAM_HEADER`) convention. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) + `ddk-create-llm-proxy-*` system-policy set | +| Cost tag: project **request** header (`X-Anypoint-Cost-Project`, #196, #522) | `core/_verify.py` `COST_PROJECT_HEADER` → transport | VERIFIED (LIVE) | **negative** — same finding as the team row: no gateway ingestion; carrier is the `donkey.cost.project` span attribute. Overridable via `cost_project_header` / `DONKEY_COST_PROJECT_HEADER`. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) | +| Cost tag: env **request** header (`X-Anypoint-Cost-Env`, #196, #522) | `core/_verify.py` `COST_ENV_HEADER` → transport | VERIFIED (LIVE) | **negative** — same finding: no gateway ingestion; carrier is the `donkey.cost.env` span attribute. Overridable via `cost_env_header` / `DONKEY_COST_ENV_HEADER`. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) | +| Cost tag: enduser id **request** header (`X-Anypoint-Cost-Enduser-Id`, #196, #522) | `core/_verify.py` `COST_ENDUSER_HEADER` → transport | VERIFIED (LIVE) | **negative** — same finding: no gateway ingestion; carrier is the `donkey.cost.enduser.id` span attribute. Overridable via `cost_enduser_header` / `DONKEY_COST_ENDUSER_HEADER`. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) | The two #195 rows are **request** headers the SDK *sends* (the client→gateway -join keys behind `donkey.run()` and `DonkeyError.correlation_id`/`.call_id`). -The verified `x-correlation-id` above is the gateway's **response** echo — a -different direction. Until an inbound-read name is confirmed against a sandbox, -both request-header names stay `Unverified(...)` placeholders and emit the verification-discipline -one-time warning; a customer whose gateway reads different names points the SDK -at them via config rather than the SDK guessing. - -The four #196 cost-tag rows follow the same discipline: the fixed dimensions -(`team` / `project` / `env` / `enduser.id`, set once via -`Donkey.from_env(team=…)` / `[donkey.cost]` / `DONKEY_COST_*`, overridable per -run via `donkey.run(team=…)`) are emitted as `X-Anypoint-Cost-*` placeholder -**request** headers *and* as `donkey.cost.*` **span** attributes. Because the -SDK controls the span end to end, the span attribute always carries the full -value regardless of the header question — so cost attribution works in tracing -today, and flips to on-the-wire headers the moment a customer sets the -`cost_*_header` overrides or the gateway-side names are verified here. +join keys behind `donkey.run()` and `DonkeyError.correlation_id`/`.call_id`). As +of #522 both are verified against the live gateway: the gateway **reads** the +inbound `X-Correlation-Id` and echoes it verbatim, so the request and response +`x-correlation-id` are the same value and a client-side log line joins to the +gateway's own record; `X-Donkey-Request-Id` is confirmed to be a **client-owned** +per-call id the gateway does not consume (it lives on `DonkeyError.call_id`). +Both names are still overridable via config for a gateway that expects different +ones, but neither is a guess and neither emits a warning. + +The four #196 cost-tag rows are verified **negative** as of #522: the LLM Gateway +has no inbound cost-tag ingestion, so the `X-Anypoint-Cost-*` request-header names +are a NON-CONTRACT — the deployed proxy's applied system policies do not read +them, and cost/usage is metered by the `llm-proxy-core` policy from token usage, +attributed by API instance + consuming client application. The fixed dimensions +(`team` / `project` / `env` / `enduser.id`, set once via `Donkey.from_env(team=…)` +/ `[donkey.cost]` / `DONKEY_COST_*`, overridable per run via `donkey.run(team=…)`) +are still emitted as `X-Anypoint-Cost-*` request headers (harmless — nothing reads +them) *and*, authoritatively, as `donkey.cost.*` **span** attributes. Because the +SDK controls the span end to end, cost attribution works in tracing regardless of +the header question; a customer whose own gateway *does* read a cost header can +point the SDK at it via the `cost_*_header` overrides. ## 4. Policy rejection response shapes (capture as fixtures, BG §1.5) diff --git a/python/src/donkey_kit/core/_verify.py b/python/src/donkey_kit/core/_verify.py index be1c3de4..96538810 100644 --- a/python/src/donkey_kit/core/_verify.py +++ b/python/src/donkey_kit/core/_verify.py @@ -81,54 +81,81 @@ def blocked(what: str) -> NotImplementedError: doc_ref="docs/verified-apis.md §3", ) -# --- Cost-attribution tag headers (docs/verified-apis.md §3, the HIGHEST-priority -# unknown, #196) --- +# --- Cost-attribution tag headers (docs/verified-apis.md §3, #196) --- # The fixed cost dimensions (team / project / env / enduser.id) are emitted as -# request headers so the gateway can group spend per dimension. The gateway-side -# header NAMES are the single most important unverified value (docs/verified-apis.md §3): the -# direct-proxy path did NOT surface them, so these are loud, overridable -# placeholders (config: ``cost_*_header``), and the value ALWAYS lands on the -# ``donkey.cost.*`` span regardless (the SDK controls the span end to end, #196 -# AC #4). The ``X-Anypoint-Cost-*`` shape mirrors the attribution placeholders -# above; it is a guess, not a confirmed name. +# request headers AND as ``donkey.cost.*`` span attributes (the SDK controls the +# span end to end, #196 AC #4). +# +# NEGATIVE result — VERIFIED (LIVE 2026-09-22, #522): the deployed Omni Gateway +# LLM proxy has NO inbound cost-tag ingestion. A live probe sending +# ``X-Anypoint-Cost-Team/Project/Env/Enduser-Id`` saw none of them echoed, and +# none of the names appears in ANY of the 10 platform-managed system policies +# applied to the proxy (checked via ``view_api_instance_policies`` on instance +# 21179672, DDK / Sandbox, and against the ``ddk-create-llm-proxy-*`` provisioning +# skills' system-policy set). Cost/usage is collected by the ``llm-proxy-core`` +# system policy from token usage (the ``x-llm-proxy-*-tokens-cost-per-1m`` +# response headers) and attributed by API instance + consuming client application +# — never by a client-sent header. So the gateway-side names are confirmed to be +# a NON-CONTRACT; the authoritative carrier is the ``donkey.cost.*`` span +# attribute. The SDK still emits these ``X-Anypoint-Cost-*`` names as a +# forward-looking, overridable (``cost_*_header``) convention — harmless because +# nothing reads them — so each is ``verified=True``: there is nothing left to +# discover, and leaving the warning on would lie by silence. COST_TEAM_HEADER = Unverified( key="cost.team_header", placeholder="X-Anypoint-Cost-Team", doc_ref="docs/verified-apis.md §3", + verified=True, ) COST_PROJECT_HEADER = Unverified( key="cost.project_header", placeholder="X-Anypoint-Cost-Project", doc_ref="docs/verified-apis.md §3", + verified=True, ) COST_ENV_HEADER = Unverified( key="cost.env_header", placeholder="X-Anypoint-Cost-Env", doc_ref="docs/verified-apis.md §3", + verified=True, ) COST_ENDUSER_HEADER = Unverified( key="cost.enduser_header", placeholder="X-Anypoint-Cost-Enduser-Id", doc_ref="docs/verified-apis.md §3", + verified=True, ) # --- Correlation / call-id request headers (BG §1.1, #195) ------------------ -# The gateway ECHOES `x-correlation-id` on RESPONSES (VERIFIED LIVE 2026-08-28, -# docs/verified-apis.md §3). Whether it READS an INBOUND correlation header — -# and under what name — is UNVERIFIED, as is any per-call request-id header. Both request-header -# names are therefore placeholders, overridable per-Donkey via config -# (``DonkeyConfig.correlation_header`` / ``.call_id_header``) so a customer can -# point them at the real names without waiting for us. ``X-Correlation-Id`` is -# the best guess precisely because it mirrors the verified response echo. +# VERIFIED (LIVE 2026-09-22, #522) against the deployed Omni Gateway proxy +# ``ddk-multi-route-fallback`` (API instance 21179672, DDK / Sandbox): +# * X-Correlation-Id (request) — POSITIVE: the gateway READS the inbound value +# and echoes it verbatim on the response ``x-correlation-id`` (probe sent +# ``X-Correlation-Id: ddk522-corr``; the response echoed ``ddk522-corr`` on +# both the 200 and the 400 paths). This is the client→gateway run/trace join +# key the SDK sends, so the name is confirmed — not a guess. It matches the +# already-verified response echo (docs/verified-apis.md §3, 2026-08-28). +# * X-Donkey-Request-Id (request) — NEGATIVE: no gateway policy reads a per-call +# id header (not echoed; absent from all 10 applied system policies). It is a +# CLIENT-OWNED per-call id surfaced on ``DonkeyError.call_id``, stable across a +# request's retries; the gateway does not need to consume it. The name is the +# SDK's own convention. +# Both are overridable per-Donkey via config (``DonkeyConfig.correlation_header`` +# / ``.call_id_header``) and both are ``verified=True``: the correlation name is +# confirmed read by the gateway, and the call-id name is confirmed to be a +# client-side construct the gateway ignores — neither is an open worklist item, +# and leaving the warning on would lie by silence. See docs/verified-apis.md §3. CORRELATION_ID_HEADER = Unverified( key="correlation.request_header", placeholder="X-Correlation-Id", doc_ref="docs/verified-apis.md §3", + verified=True, ) CALL_ID_HEADER = Unverified( key="correlation.call_id_header", placeholder="X-Donkey-Request-Id", doc_ref="docs/verified-apis.md §3", + verified=True, ) # --- Control-plane token endpoint (docs/verified-apis.md §1) ---------------- diff --git a/python/src/donkey_kit/core/transport.py b/python/src/donkey_kit/core/transport.py index f6f35de5..2e5a8052 100644 --- a/python/src/donkey_kit/core/transport.py +++ b/python/src/donkey_kit/core/transport.py @@ -114,10 +114,11 @@ def cost_headers(cfg: DonkeyConfig, tags: CostTags) -> dict[str, str]: (docs/verified-apis.md §3, BG §1.7, #196). Each header NAME is the config override (``cost_*_header``) if set, else the - UNVERIFIED placeholder from ``core/_verify`` — the gateway-side cost header - name is the single highest-priority unknown (docs/verified-apis.md §3), so - an un-overridden name emits the one-time verification-discipline warning. - Values are pre-validated by + placeholder from ``core/_verify``. As of #522 the gateway-side cost header + names are a VERIFIED-NEGATIVE result (docs/verified-apis.md §3): the LLM + Gateway ingests no cost-tag header, so the name is a forward-looking + convention and reading the placeholder emits no warning — the authoritative + carrier is the ``donkey.cost.*`` span attribute. Values are pre-validated by :class:`CostTags`, so they are always header-safe.""" override = { field: getattr(cfg, attr) for field, attr, _placeholder in _COST_HEADER_SOURCES @@ -204,10 +205,12 @@ def proxy_api_key(cfg: DonkeyConfig) -> str: def _resolve_header_names(cfg: DonkeyConfig) -> tuple[str, str]: """The ``(correlation, call_id)`` request-header NAMES for this config (BG §1.1, - #195): each is the config override if set, else the UNVERIFIED placeholder - from ``core/_verify``. Called ONCE per client at construction, so the - one-time unverified warning for an un-overridden name fires there, not on - every request.""" + #195): each is the config override if set, else the placeholder from + ``core/_verify``. As of #522 both are verified (docs/verified-apis.md §3): the + gateway reads the inbound ``X-Correlation-Id`` (echoed verbatim), and + ``X-Donkey-Request-Id`` is a confirmed client-owned per-call id the gateway + does not consume — so neither placeholder warns. Called ONCE per client at + construction, not on every request.""" correlation = cfg.correlation_header or _verify.CORRELATION_ID_HEADER.get() call_id = cfg.call_id_header or _verify.CALL_ID_HEADER.get() return correlation, call_id diff --git a/python/tests/sandbox/test_proxy_contracts.py b/python/tests/sandbox/test_proxy_contracts.py index 78381b1f..366f121c 100644 --- a/python/tests/sandbox/test_proxy_contracts.py +++ b/python/tests/sandbox/test_proxy_contracts.py @@ -12,16 +12,45 @@ from __future__ import annotations +import warnings from collections.abc import Callable import pytest +from donkey_kit.core._verify import UnverifiedValueWarning from donkey_kit.core.errors import DonkeyError, classify from donkey_kit.donkey import Donkey pytestmark = pytest.mark.sandbox +def test_run_scope_attribution_is_warning_free( + open_proxy: Callable[[str], Donkey], + model_for: Callable[[str], str], +) -> None: + """#522: the six inbound correlation / cost / per-call-id request-header names + are live-verified (``X-Correlation-Id`` is read + echoed by the gateway; the + cost and per-call-id names are a confirmed non-contract the gateway ignores), + so a default call under ``donkey.run(team=…)`` — every cost dimension set, no + header-name overrides, no application/business-group attribution — emits NONE + of the ``UnverifiedValueWarning``s that used to fire on this path. Escalate + that warning to an error so the quiet path is pinned against a REAL proxy, not + just a unit mock (the live twin of the group-1 acceptance assertion).""" + donkey = open_proxy("openai-model-routing") + with warnings.catch_warnings(): + warnings.simplefilter("error", UnverifiedValueWarning) + client = donkey.llm.client(sync=True) + with donkey.run( + id="ddk522-live", team="acceptance", project="ddk", env="sandbox", enduser_id="u-1" + ): + resp = client.responses.create( + model=model_for("openai-model-routing"), + input="Reply with the single word: hello.", + ) + + assert resp.status == "completed" + + def test_openai_routing_happy_path_matches_fixture( open_proxy: Callable[[str], Donkey], model_for: Callable[[str], str], diff --git a/python/tests/unit/test_transport.py b/python/tests/unit/test_transport.py index 7f4aece4..296f5c38 100644 --- a/python/tests/unit/test_transport.py +++ b/python/tests/unit/test_transport.py @@ -76,7 +76,9 @@ def handler(request: httpx.Request) -> httpx.Response: cfg = DonkeyConfig(application_name="hr-agent", business_group="finance") async with _client(handler, cfg) as client: - # placeholder header names warn (verification discipline) + # The correlation header name is VERIFIED as of #522, so the warning here + # comes from the still-UNVERIFIED application/business-group attribution + # placeholder names (verification discipline). with pytest.warns(UnverifiedValueWarning): with run_context("run-1"): await client.get("https://x/thing") @@ -1519,16 +1521,71 @@ def test_attribution_headers_snapshot_carries_config_cost() -> None: assert headers["x-cost-project"] == "triage-v2" -def test_cost_headers_uses_placeholder_name_when_unoverridden() -> None: - # With no config override, the header NAME is the loud UNVERIFIED placeholder. +def test_cost_headers_use_placeholder_name_without_warning() -> None: + # #522: the gateway-side cost header names are a VERIFIED-NEGATIVE result — the + # deployed proxy has no inbound cost-tag ingestion, so the name is a + # forward-looking convention, not an open unknown. With no config override the + # header NAME is the placeholder AND the quiet default path emits NO + # UnverifiedValueWarning (verified=True). Escalate the warning to an error so a + # regression that re-arms it is caught here. from donkey_kit.core import _verify cfg = DonkeyConfig(cost=CostTags(team="support")) - with pytest.warns(UnverifiedValueWarning): + with warnings.catch_warnings(): + warnings.simplefilter("error", UnverifiedValueWarning) headers = cost_headers(cfg, cfg.cost) assert headers[_verify.COST_TEAM_HEADER.placeholder] == "support" +def test_verified_inbound_attribution_headers_are_quiet() -> None: + """#522: the six inbound correlation / cost / per-call-id request-header names + are live-verified — ``X-Correlation-Id`` is read by the gateway, and the cost + + per-call-id names are confirmed as a client-side / non-contract shape (the + gateway ingests no such header). All six placeholders are ``verified=True``, so + reading any of them emits NO ``UnverifiedValueWarning``. Pins the quiet path so + a regression that re-arms the warning is caught in CI.""" + from donkey_kit.core import _verify + + quiet = ( + _verify.CORRELATION_ID_HEADER, + _verify.CALL_ID_HEADER, + _verify.COST_TEAM_HEADER, + _verify.COST_PROJECT_HEADER, + _verify.COST_ENV_HEADER, + _verify.COST_ENDUSER_HEADER, + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", UnverifiedValueWarning) + for placeholder in quiet: + assert placeholder.verified is True + assert placeholder.get() # reading it must not warn/raise + + +async def test_default_run_with_cost_tags_emits_no_unverified_warning() -> None: + """#522 AC: a default call under ``donkey.run(team=…)`` — cost tags set, no + header-name overrides, no application/business-group attribution — emits NONE of + the six ``UnverifiedValueWarning``s that used to fire (``X-Correlation-Id`` / + ``X-Donkey-Request-Id`` / ``X-Anypoint-Cost-*``). Unit-level mirror of the + acceptance group-1 quiet-path assertion.""" + from donkey_kit.core import _verify + + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(request.headers) + return httpx.Response(200) + + cfg = DonkeyConfig(cost=CostTags(team="support", project="triage", env="prod")) + with warnings.catch_warnings(): + warnings.simplefilter("error", UnverifiedValueWarning) + async with _client(handler, cfg) as client: + with run_context("run-522"): + await client.get("https://x/thing") + + assert seen[CORRELATION_HEADER.lower()] == "run-522" + assert seen[_verify.COST_TEAM_HEADER.placeholder.lower()] == "support" + + def test_effective_cost_tags_merges_run_over_config() -> None: cfg = _COST_CFG.with_overrides(cost=CostTags(team="support", env="prod")) assert effective_cost_tags(cfg) == cfg.cost # no run scope: config as-is diff --git a/website/content/concepts/observability.mdx b/website/content/concepts/observability.mdx index 2efffc01..a0bd5ae9 100644 --- a/website/content/concepts/observability.mdx +++ b/website/content/concepts/observability.mdx @@ -114,12 +114,13 @@ ids are pure headers and contextvars; the span only decorates when OTel is present. - The gateway echoes a `x-correlation-id` on its **response**, which is verified. - Whether it *reads* an inbound `X-Correlation-Id` (and `X-Donkey-Request-Id`) as - the join key is not yet confirmed, so those request-header **names** are - overridable placeholders — set `correlation_header` / `call_id_header` in - config if your gateway expects different names - ([Verification policy](/concepts/verification)). + **Verified (#522).** The gateway echoes `x-correlation-id` on its **response** + *and* reads the inbound `X-Correlation-Id` — a probe confirmed the response + echoes the client-sent value verbatim, so it is the real join key. + `X-Donkey-Request-Id` is a client-owned per-call id the gateway does not read + (it lives on `DonkeyError.call_id`). Both names stay overridable + (`correlation_header` / `call_id_header`) if your gateway expects different + names ([Verification policy](/concepts/verification)). ## Streaming, refusals, and exceptions diff --git a/website/content/telemetry.mdx b/website/content/telemetry.mdx index 420f2376..9e52108b 100644 --- a/website/content/telemetry.mdx +++ b/website/content/telemetry.mdx @@ -276,12 +276,14 @@ and is usable bare (`@donkey.governed`) or parametrised. There is deliberately a single correlation, so when you need a specific id, reach for `donkey.run(id=…)` directly. - - Whether the gateway **reads** an inbound `X-Correlation-Id` / - `X-Donkey-Request-Id` is unverified — `x-correlation-id` is confirmed only as a - gateway **response** echo. So those request-header **names** are overridable - placeholders (`correlation_header` / `call_id_header`), not guesses - ([Verification policy](/concepts/verification)). + + **Verified (#522).** The gateway **reads** the inbound `X-Correlation-Id` and + echoes it verbatim on the response, so the request and response + `x-correlation-id` are the same value — the client→gateway join key is real. + `X-Donkey-Request-Id` is a **client-owned** per-call id the gateway does not + consume; it lives on `DonkeyError.call_id`. Both names stay overridable + (`correlation_header` / `call_id_header`) for a gateway that expects different + ones ([Verification policy](/concepts/verification)). ## Cost-attribution tags @@ -322,11 +324,14 @@ to the gateway record, and the span carries `enduser.id` and `donkey.policy.type`. That is what an EU AI Act Article 12 log request looks like in practice — one query, not an investigation. - - **Which header names the gateway reads for attribution is unverified.** Until - it is confirmed, those names live behind an overridable placeholder rather - than a guess. The tags still carry full value in the OTel spans, which this - SDK controls end to end. + + **Verified negative (#522).** The Anypoint LLM Gateway has no inbound cost-tag + ingestion — it meters cost from token usage per API instance and consuming + client application, not from a client header. So the `X-Anypoint-Cost-*` + request-header names are a forward-looking convention (harmless — nothing reads + them); the **authoritative** carrier is the `donkey.cost.*` OTel span attribute, + which this SDK controls end to end. The names stay overridable (`cost_*_header`) + for a gateway that does read one. The tags are **validated** — fixed keys, bounded length — so nobody stuffs a diff --git a/website/public/concepts/observability.md b/website/public/concepts/observability.md index a5fa1f04..fec4f3e6 100644 --- a/website/public/concepts/observability.md +++ b/website/public/concepts/observability.md @@ -103,12 +103,13 @@ generated "run of one". Correlation works with or without OpenTelemetry — the ids are pure headers and contextvars; the span only decorates when OTel is present. - The gateway echoes a `x-correlation-id` on its **response**, which is verified. - Whether it *reads* an inbound `X-Correlation-Id` (and `X-Donkey-Request-Id`) as - the join key is not yet confirmed, so those request-header **names** are - overridable placeholders — set `correlation_header` / `call_id_header` in - config if your gateway expects different names - ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). + **Verified (#522).** The gateway echoes `x-correlation-id` on its **response** + *and* reads the inbound `X-Correlation-Id` — a probe confirmed the response + echoes the client-sent value verbatim, so it is the real join key. + `X-Donkey-Request-Id` is a client-owned per-call id the gateway does not read + (it lives on `DonkeyError.call_id`). Both names stay overridable + (`correlation_header` / `call_id_header`) if your gateway expects different + names ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). ## Streaming, refusals, and exceptions diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index 6259c888..34122bfe 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -2939,11 +2939,13 @@ and is usable bare (`@donkey.governed`) or parametrised. There is deliberately a single correlation, so when you need a specific id, reach for `donkey.run(id=…)` directly. - Whether the gateway **reads** an inbound `X-Correlation-Id` / - `X-Donkey-Request-Id` is unverified — `x-correlation-id` is confirmed only as a - gateway **response** echo. So those request-header **names** are overridable - placeholders (`correlation_header` / `call_id_header`), not guesses - ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). + **Verified (#522).** The gateway **reads** the inbound `X-Correlation-Id` and + echoes it verbatim on the response, so the request and response + `x-correlation-id` are the same value — the client→gateway join key is real. + `X-Donkey-Request-Id` is a **client-owned** per-call id the gateway does not + consume; it lives on `DonkeyError.call_id`. Both names stay overridable + (`correlation_header` / `call_id_header`) for a gateway that expects different + ones ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). ## Cost-attribution tags @@ -2981,10 +2983,13 @@ to the gateway record, and the span carries `enduser.id` and `donkey.policy.type`. That is what an EU AI Act Article 12 log request looks like in practice — one query, not an investigation. - **Which header names the gateway reads for attribution is unverified.** Until - it is confirmed, those names live behind an overridable placeholder rather - than a guess. The tags still carry full value in the OTel spans, which this - SDK controls end to end. + **Verified negative (#522).** The Anypoint LLM Gateway has no inbound cost-tag + ingestion — it meters cost from token usage per API instance and consuming + client application, not from a client header. So the `X-Anypoint-Cost-*` + request-header names are a forward-looking convention (harmless — nothing reads + them); the **authoritative** carrier is the `donkey.cost.*` OTel span attribute, + which this SDK controls end to end. The names stay overridable (`cost_*_header`) + for a gateway that does read one. The tags are **validated** — fixed keys, bounded length — so nobody stuffs a JSON blob into a header. @@ -4898,12 +4903,13 @@ generated "run of one". Correlation works with or without OpenTelemetry — the ids are pure headers and contextvars; the span only decorates when OTel is present. - The gateway echoes a `x-correlation-id` on its **response**, which is verified. - Whether it *reads* an inbound `X-Correlation-Id` (and `X-Donkey-Request-Id`) as - the join key is not yet confirmed, so those request-header **names** are - overridable placeholders — set `correlation_header` / `call_id_header` in - config if your gateway expects different names - ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). + **Verified (#522).** The gateway echoes `x-correlation-id` on its **response** + *and* reads the inbound `X-Correlation-Id` — a probe confirmed the response + echoes the client-sent value verbatim, so it is the real join key. + `X-Donkey-Request-Id` is a client-owned per-call id the gateway does not read + (it lives on `DonkeyError.call_id`). Both names stay overridable + (`correlation_header` / `call_id_header`) if your gateway expects different + names ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). ## Streaming, refusals, and exceptions diff --git a/website/public/telemetry.md b/website/public/telemetry.md index e62b0ad9..af757bdf 100644 --- a/website/public/telemetry.md +++ b/website/public/telemetry.md @@ -256,11 +256,13 @@ and is usable bare (`@donkey.governed`) or parametrised. There is deliberately a single correlation, so when you need a specific id, reach for `donkey.run(id=…)` directly. - Whether the gateway **reads** an inbound `X-Correlation-Id` / - `X-Donkey-Request-Id` is unverified — `x-correlation-id` is confirmed only as a - gateway **response** echo. So those request-header **names** are overridable - placeholders (`correlation_header` / `call_id_header`), not guesses - ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). + **Verified (#522).** The gateway **reads** the inbound `X-Correlation-Id` and + echoes it verbatim on the response, so the request and response + `x-correlation-id` are the same value — the client→gateway join key is real. + `X-Donkey-Request-Id` is a **client-owned** per-call id the gateway does not + consume; it lives on `DonkeyError.call_id`. Both names stay overridable + (`correlation_header` / `call_id_header`) for a gateway that expects different + ones ([Verification policy](https://donkey-development-kit.github.io/donkey-development-kit/concepts/verification.md)). ## Cost-attribution tags @@ -298,10 +300,13 @@ to the gateway record, and the span carries `enduser.id` and `donkey.policy.type`. That is what an EU AI Act Article 12 log request looks like in practice — one query, not an investigation. - **Which header names the gateway reads for attribution is unverified.** Until - it is confirmed, those names live behind an overridable placeholder rather - than a guess. The tags still carry full value in the OTel spans, which this - SDK controls end to end. + **Verified negative (#522).** The Anypoint LLM Gateway has no inbound cost-tag + ingestion — it meters cost from token usage per API instance and consuming + client application, not from a client header. So the `X-Anypoint-Cost-*` + request-header names are a forward-looking convention (harmless — nothing reads + them); the **authoritative** carrier is the `donkey.cost.*` OTel span attribute, + which this SDK controls end to end. The names stay overridable (`cost_*_header`) + for a gateway that does read one. The tags are **validated** — fixed keys, bounded length — so nobody stuffs a JSON blob into a header.