From f394ac857778bec034609ae7910b22987ffc43b6 Mon Sep 17 00:00:00 2001 From: zendaya Date: Sun, 3 May 2026 20:14:14 +0200 Subject: [PATCH 1/2] feat: update ruff version constraint and enhance troubleshooting documentation - Changed the ruff dependency in `pyproject.toml` from an exact version (`ruff==0.15.12`) to a range (`ruff>=0.15,<0.16`) to allow for more flexible version resolution. - Updated the troubleshooting documentation to reflect the new ruff versioning scheme, clarifying how to check the version used in CI and its relation to `uv.lock`. - Added a new method `list_distinct_pricing_versions` in the Storage class to retrieve all distinct pricing versions from the ledger. - Introduced a new command `pricing check` in the CLI to assess the age of bundled pricing tables and warn if they exceed a specified threshold, enhancing the management of pricing data staleness. --- CHANGELOG.md | 11 + README.md | 10 +- RELEASE_NOTES.md | 6 + ROADMAP.md | 1 + docs/pricing-catalog.md | 6 + docs/sdk-integrations.md | 22 + docs/sdk.md | 4 +- docs/troubleshooting.md | 4 +- pyproject.toml | 4 +- src/flightdeck/bundled_pricing/anthropic.yaml | 1 + src/flightdeck/bundled_pricing/catalog.yaml | 2 + src/flightdeck/bundled_pricing/google.yaml | 1 + src/flightdeck/bundled_pricing/openai.yaml | 1 + src/flightdeck/cli/main.py | 60 ++- src/flightdeck/operations.py | 11 + src/flightdeck/sdk/client.py | 385 ++++++++---------- src/flightdeck/storage.py | 8 + 17 files changed, 299 insertions(+), 238 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e96ae..1ed8770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This project follows [Semantic Versioning](https://semver.org/). From **v1.0.0** ## Unreleased +### Added + +- **`flightdeck pricing check`** — reports **`flightdeck-bundled-*`** snapshot age vs **`--max-age-days`** (default **90**); **`--fail`** for CI. **`release diff`** / **`POST /v1/diff`** append **`pricing.warnings`** when bundled snapshots exceed the same age threshold. +- **`flightdeck.integrations.telemetry.configure_otel_tracing()`** — optional OTLP HTTP **`TracerProvider`** wiring when the **`telemetry`** extra is installed (see **`docs/sdk-integrations.md`**). +- **SDK:** **`flightdeck.sdk.http_common`** shared serializers and retry policy; parity tests keep sync/async clients aligned. **`pytest-cov`** no longer omits **`sdk/client.py`**. + +### Changed + +- **`[project.optional-dependencies] dev`:** **`ruff`** is **`>=0.15,<0.16`** (was an exact patch pin) so **`pip install`** / shared venvs can resolve alongside other tools; **`uv sync --frozen`** still follows **`uv.lock`**. **`docs/troubleshooting.md`** notes checking **`uv.lock`** for the resolved **`0.15.x`** wheel. +- **Docs / positioning:** README local-first and ICP copy; bundled pricing cadence, vendor pricing URLs in YAML comments, and **`docs/pricing-catalog.md`** / **ROADMAP** / **RELEASE_NOTES** staleness commitments. + ## 1.2.0 - 2026-05-03 ### Breaking diff --git a/README.md b/README.md index 9c49321..49ca963 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ship AI agents safely with release diffs, runtime evidence, and policy gates.** -FlightDeck is **local-first** (CLI + SQLite + optional **`flightdeck serve`** UI): run evidence, pricing tables, and the ledger **stay on disk in your environment** by default—**no trace or billing payload is sent to FlightDeck as a vendor**. That posture matters for **regulated**, **air-gapped**, and **data-sovereignty** teams that cannot ship telemetry to a third-party SaaS observability backend. It is not an agent framework, prompt IDE, tracing dashboard, or gateway — it is where **what shipped**, **what ran**, **what it cost**, and **whether promote is allowed** are recorded and compared. +FlightDeck is **local-first** (CLI + SQLite + optional **`flightdeck serve`** UI): run evidence, pricing tables, and the ledger **stay on disk in your environment** by default—**no data leaves your infrastructure** for FlightDeck’s own product telemetry (there is no vendor backend that ingests your runs or tariffs). **No trace or billing payload is sent to FlightDeck as a vendor.** That posture matters for **regulated**, **air-gapped**, and **data-sovereignty** teams that cannot ship telemetry to a third-party SaaS observability backend. It is not an agent framework, prompt IDE, tracing dashboard, or gateway — it is where **what shipped**, **what ran**, **what it cost**, and **whether promote is allowed** are recorded and compared. ## In ~20 seconds @@ -17,10 +17,10 @@ You ship a candidate whose **system prompt drifts by a handful of tokens**; unde ## Who should use this? -- **Primary buyer / ICP:** **Platform or ML engineering teams** (often **5–30** people) at **growth-stage** companies shipping **two or more** **LLM agents** to production—especially teams that already had a **cost** or **regression** incident from a **prompt** or **model** change and need a **governed** promote path. +- **Growth-stage ICP:** **Platform or ML engineering teams** (often **5–30** people) at companies shipping **two or more** **LLM agents** to production—especially after a **cost** or **regression** incident from a **prompt** or **model** change—who need a **fast, governed** promote path without standing up a hosted observability product. +- **Regulated / enterprise ICP:** **Platform and SRE teams** in **healthcare, fintech, and similar** environments where buying criteria center on **data residency**, **audit trails**, and **provable control** over evidence and pricing data—**local-first** defaults and optional self-hosted **`flightdeck serve`** match a compliance-led evaluation, not only a velocity-led one. - Teams that **version agent builds** (prompts, tools, model pins) and need a **durable audit trail**. - Engineers who want **one command** to answer “is this candidate safe to roll forward?” with **numbers**, not gut feel. -- **Healthcare, fintech, and enterprise** operators who **cannot** default to sending traces or cost data to a **hosted** observability vendor—**local-first** evidence and pricing imports are the default integration model. - Anyone who has outgrown **ad hoc** folder diffs or **spreadsheet** promote checklists. ## How FlightDeck fits your stack @@ -83,7 +83,7 @@ Not implemented yet: - hosted control plane - automated traffic routing - tool-cost pricing -- OpenTelemetry import/export mapping (optional **`uv sync --extra telemetry`** or **`pip install 'flightdeck-ai[telemetry]'`** for future work) +- OpenTelemetry: optional **`telemetry`** extra installs OTLP-capable SDK packages; call **`flightdeck.integrations.telemetry.configure_otel_tracing()`** once to wire an OTLP span exporter to **your** collector (see **[docs/sdk-integrations.md](docs/sdk-integrations.md)**) Shipped locally: @@ -128,7 +128,7 @@ Or use the bash wrapper (Git Bash / WSL on Windows): ./scripts/smoke.sh ``` -**Bundled pricing (default `init`):** **`flightdeck init`** migrates the ledger, imports **OpenAI**, **Anthropic**, and **Google** (Gemini-class) tables at **`pricing_version` `flightdeck-bundled-2026-05`**, and writes **`.flightdeck/pricing-catalog.yaml`** with **`pricing_catalog_path`** set in **`flightdeck.yaml`**. In **`release.yaml`**, set **`spec.pricing_reference`** to `{ provider: openai | anthropic | google, pricing_version: flightdeck-bundled-2026-05 }` to get **per-table** and **catalog** cost lines on diffs without authoring YAML. These rates are a **convenience snapshot**, not live vendor billing—**`flightdeck pricing import`** your own files for production. Use **`flightdeck init --no-bundled-pricing`** for an empty ledger. +**Bundled pricing (default `init`):** **`flightdeck init`** migrates the ledger, imports **OpenAI**, **Anthropic**, and **Google** (Gemini-class) tables at **`pricing_version` `flightdeck-bundled-2026-05`**, and writes **`.flightdeck/pricing-catalog.yaml`** with **`pricing_catalog_path`** set in **`flightdeck.yaml`**. In **`release.yaml`**, set **`spec.pricing_reference`** to `{ provider: openai | anthropic | google, pricing_version: flightdeck-bundled-2026-05 }` to get **per-table** and **catalog** cost lines on diffs without authoring YAML. These rates are a **convenience snapshot**, not live vendor billing—**`flightdeck pricing import`** your own files for production. Use **`flightdeck init --no-bundled-pricing`** for an empty ledger. Official list-pricing URLs are referenced in comments atop the bundled YAML under **`src/flightdeck/bundled_pricing/`**. **`flightdeck pricing check`** flags bundled snapshots older than **90 days** (use **`--fail`** in CI); **`release diff`** adds **`pricing.warnings`** for the same condition so cost lines do not go silently stale. **Release policy:** bundled tables are **refreshed with each minor release** when vendor public list pricing changes materially (see **[ROADMAP.md](ROADMAP.md)**). Or walk through the **full quickstart** (policy + **two** custom tariffs for the **~31%** narrative—same flow CI runs): diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a1a53ed..90f85d2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -4,6 +4,12 @@ High-level notes for **shipping FlightDeck**. Detailed history: **[CHANGELOG.md] Narrative docs (including the CLI reference) are maintained on **[github.com/flightdeckdev/flightdeck](https://github.com/flightdeckdev/flightdeck)** `main`; this file and **`schemas/`** ship in minimal clones. +## Unreleased (in development) + +- **Bundled pricing hygiene:** **`flightdeck pricing check`** reports **`flightdeck-bundled-*`** snapshot age vs **`--max-age-days`** (default **90**); **`--fail`** exits non-zero for CI. **`release diff`** / **`POST /v1/diff`** append **`pricing.warnings`** for the same staleness rule so cost signals do not go silently wrong. Bundled YAML gains vendor **official pricing** URL comments; docs and **ROADMAP** state a **minor-release refresh** cadence for the bundled snapshot when list prices move materially. +- **Contributor tooling:** **`[project.optional-dependencies] dev`** uses **`ruff>=0.15,<0.16`** (see **`CHANGELOG.md`**). +- **Telemetry extra:** optional **`flightdeck.integrations.telemetry.configure_otel_tracing()`** wires OTLP span export to **your** backend (see **`docs/sdk-integrations.md`**). + ## v1.2.0 — Python 3.11+, protected ingest and reads, bundled pricing, Postgres, integrations Minor release (see **[CHANGELOG.md](CHANGELOG.md)** for the full list). diff --git a/ROADMAP.md b/ROADMAP.md index 2fb80da..5615ae3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,7 @@ This document is **strategy and ordering**, not a second changelog. It goes from - **Evidence ingestion:** `runs ingest` from JSONL/JSON arrays plus stable `POST /v1/events` (`schemas/v1/`); **`GET /v1/runs`**, **`runs list`**, optional **`trace_id`** filter, and **`runs export`** (JSONL) for operator forensics. - **Local API + UI:** `flightdeck serve` routes and shipped web bundle under `src/flightdeck/server/static/`; surfaces summarized in **Web UI and operator experience** below. - **SDK and tooling:** Python sync/async clients with retries/batching and `flightdeck-quickstart-verify`. +- **Bundled default pricing:** convenience **`flightdeck-bundled-YYYY-MM`** tables from **`flightdeck init`**; **refreshed on each minor release** when upstream public list pricing changes materially, with **`flightdeck pricing check`** / diff **`pricing.warnings`** guarding silent staleness (operators still **`pricing import`** for production truth). - **Operator references:** CI examples, deploy/Compose guidance, Helm and fleet examples under `examples/`. --- diff --git a/docs/pricing-catalog.md b/docs/pricing-catalog.md index 0edb51a..ce150c3 100644 --- a/docs/pricing-catalog.md +++ b/docs/pricing-catalog.md @@ -22,6 +22,12 @@ for the side you want priced. For **Gemini-class** models, use **`provider: goog release runtime and pricing reference. For production accuracy, **`flightdeck pricing import`** your own YAML (and optionally **`--replace`** with **`--reason`**). +Bundled table YAML in the wheel includes **comment links** to each provider’s official list-pricing page so you can spot-check rates between FlightDeck releases. + +**Staleness guardrails:** list prices change often. Run **`flightdeck pricing check`** to see whether any **`flightdeck-bundled-*`** table in the ledger is older than **`--max-age-days`** (default **90**); pass **`--fail`** for CI. **`flightdeck release diff`** and **`POST /v1/diff`** add **`pricing.warnings`** when baseline or candidate **`pricing_version`** is a stale bundled snapshot so economics do not look authoritative after the snapshot has aged out. + +**Maintainer cadence:** the bundled snapshot is **updated on each minor release** when vendor public list pricing changes materially (see **[ROADMAP.md](../ROADMAP.md)**). Operators in production should still treat **`flightdeck pricing import`** as the source of truth. + ## Relationship to `pricing.prices` On a diff, **`pricing.prices`** (when present) reflects **per-side imported tables** for the resolved baseline/candidate diff --git a/docs/sdk-integrations.md b/docs/sdk-integrations.md index 1169281..7c0a329 100644 --- a/docs/sdk-integrations.md +++ b/docs/sdk-integrations.md @@ -26,10 +26,32 @@ product surface for orchestration. | **`integrations-temporal`** | Install **`temporalio`** next to FlightDeck when your worker shares a venv | | **`integrations-openai-agents`** | **`openai-agents`** for result-shape experiments | | **`integrations-ci`** | Meta-extra for CI: LangChain + Temporal + OpenAI Agents resolution | +| **`telemetry`** | OpenTelemetry SDK + OTLP exporter packages; wire with **`flightdeck.integrations.telemetry.configure_otel_tracing()`** (see below) | +| **`all`** | Convenience bundle including **`telemetry`** | There is **no** **`crewai`** extra on the distribution. Use **`crewai_bridge.run_event_from_crew_token_totals`** with totals you collect from CrewAI (or install **`crewai`** only in your application environment). +## OpenTelemetry (`telemetry` extra) + +Install **`flightdeck-ai[telemetry]`** (or **`uv sync --extra telemetry`**), then once per process: + +```python +from flightdeck.integrations.telemetry import configure_otel_tracing + +configure_otel_tracing() +``` + +This registers an OpenTelemetry **SDK** `TracerProvider` with an **OTLP HTTP** span exporter and +batch processor. Set **`OTEL_EXPORTER_OTLP_ENDPOINT`** (for example +`http://127.0.0.1:4318/v1/traces`) and optional **`OTEL_EXPORTER_OTLP_HEADERS`** / +**`OTEL_SERVICE_NAME`** as documented for **`opentelemetry-exporter-otlp`**. Spans are sent to +**your** collector, not to FlightDeck as a vendor. A second call is a no-op unless you pass +**`force=True`** (rebinds the provider—use sparingly in tests). + +FlightDeck does not auto-instrument **`httpx`** or the Python SDK; create spans in your app or +attach upstream auto-instrumentation if you need request-level traces. + ## Trust boundaries Anyone who can reach **`POST /v1/events`** can append ledger rows. Keep **`flightdeck serve`** diff --git a/docs/sdk.md b/docs/sdk.md index 8bd687d..08a8f09 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -2,7 +2,9 @@ `flightdeck.sdk` is a thin HTTP client for emitting runtime evidence and triggering release actions against a running `flightdeck serve` instance. It ships with the same SemVer as the -CLI; see [RELEASE_NOTES.md](../RELEASE_NOTES.md) for stability expectations. +CLI; see [RELEASE_NOTES.md](../RELEASE_NOTES.md) for stability expectations. Internally, +**`flightdeck.sdk.http_common`** holds shared URL/header helpers, JSON/query serializers, and +retry loops so **`FlightdeckClient`** (sync) and **`AsyncFlightdeckClient`** stay wire-identical. For most workflows the CLI is sufficient. Use the SDK when you need to: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ad90c37..1e6afeb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -68,8 +68,8 @@ Set `FLIGHTDECK_USE_SYSTEM_TEMP=1` to force pytest to use the OS default path ag Run `uv run python -m ruff check --fix src tests` to apply auto-fixable issues. For remaining errors, read the rule code (e.g. `E501`, `F401`) in the output and fix manually. -Check what ruff version CI uses: `uv run python -m ruff --version` (must match `ruff==0.15.12` -pinned in `pyproject.toml [project.optional-dependencies] dev`). +Check what ruff version CI uses: `uv run python -m ruff --version` (CI resolves **`ruff>=0.15,<0.16`** +from `pyproject.toml [project.optional-dependencies] dev`; see **`uv.lock`** for the exact wheel). --- diff --git a/pyproject.toml b/pyproject.toml index 7d50194..5faa96e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ all = [ dev = [ "pytest>=7.0", "pytest-cov>=4.0", - "ruff==0.15.12", + "ruff>=0.15,<0.16", ] postgres = [ "psycopg[binary]>=3.2", @@ -98,8 +98,6 @@ source = ["src/flightdeck"] omit = [ "src/flightdeck/quickstart_smoke.py", "src/flightdeck/integrations/*", - # Thin HTTP wrapper; core policy/diff/storage coverage is the governance bar. - "src/flightdeck/sdk/client.py", ] [tool.coverage.report] diff --git a/src/flightdeck/bundled_pricing/anthropic.yaml b/src/flightdeck/bundled_pricing/anthropic.yaml index 4d4e2a0..5d67265 100644 --- a/src/flightdeck/bundled_pricing/anthropic.yaml +++ b/src/flightdeck/bundled_pricing/anthropic.yaml @@ -1,6 +1,7 @@ # FlightDeck bundled snapshot — illustrative public list prices, not live vendor APIs. # For production accuracy, run: flightdeck pricing import # Snapshot id: flightdeck-bundled-2026-05 (see README / docs/pricing-catalog.md). +# Official vendor list pricing (verify before trusting this file): https://www.anthropic.com/pricing provider: anthropic pricing_version: flightdeck-bundled-2026-05 entries: diff --git a/src/flightdeck/bundled_pricing/catalog.yaml b/src/flightdeck/bundled_pricing/catalog.yaml index 7ad2f34..e1b8aae 100644 --- a/src/flightdeck/bundled_pricing/catalog.yaml +++ b/src/flightdeck/bundled_pricing/catalog.yaml @@ -1,5 +1,7 @@ # Bundled PricingCatalog — maps bundled pricing tables to one comparable slot per tier. # api_version / kind per schemas/v1/pricing_catalog.schema.json +# Compare bundled rows with vendor pages: https://openai.com/api/pricing/ +# https://www.anthropic.com/pricing https://ai.google.dev/gemini-api/docs/pricing api_version: v1 kind: PricingCatalog catalog_version: flightdeck-bundled-2026-05 diff --git a/src/flightdeck/bundled_pricing/google.yaml b/src/flightdeck/bundled_pricing/google.yaml index 85c43c1..088ca52 100644 --- a/src/flightdeck/bundled_pricing/google.yaml +++ b/src/flightdeck/bundled_pricing/google.yaml @@ -2,6 +2,7 @@ # Provider key "google" is the supported convention for Gemini-class models in release.yaml. # For production accuracy, run: flightdeck pricing import # Snapshot id: flightdeck-bundled-2026-05 (see README / docs/pricing-catalog.md). +# Official vendor list pricing (verify before trusting this file): https://ai.google.dev/gemini-api/docs/pricing provider: google pricing_version: flightdeck-bundled-2026-05 entries: diff --git a/src/flightdeck/bundled_pricing/openai.yaml b/src/flightdeck/bundled_pricing/openai.yaml index cc104dc..44118ea 100644 --- a/src/flightdeck/bundled_pricing/openai.yaml +++ b/src/flightdeck/bundled_pricing/openai.yaml @@ -1,6 +1,7 @@ # FlightDeck bundled snapshot — illustrative public list prices, not live vendor APIs. # For production accuracy, run: flightdeck pricing import # Snapshot id: flightdeck-bundled-2026-05 (see README / docs/pricing-catalog.md). +# Official vendor list pricing (verify before trusting this file): https://openai.com/api/pricing/ provider: openai pricing_version: flightdeck-bundled-2026-05 entries: diff --git a/src/flightdeck/cli/main.py b/src/flightdeck/cli/main.py index cb8e445..a1e832b 100644 --- a/src/flightdeck/cli/main.py +++ b/src/flightdeck/cli/main.py @@ -289,7 +289,7 @@ def release_verify(release_id: str, artifact_path: Path) -> None: @cli.group() def pricing() -> None: - """Import and view pricing tables.""" + """Import, inspect, and check staleness of pricing tables.""" @pricing.command("import") @@ -332,6 +332,64 @@ def pricing_show(provider: str, pricing_version: str) -> None: click.echo(table.model_dump_json(indent=2)) +@pricing.command("check") +@click.option( + "--max-age-days", + default=90, + show_default=True, + type=int, + help="Warn when a bundled snapshot anchor is older than this many days.", +) +@click.option( + "--fail", + is_flag=True, + default=False, + help="Exit with code 1 if any bundled snapshot exceeds --max-age-days.", +) +def pricing_check(max_age_days: int, fail: bool) -> None: + """Check age of ``flightdeck-bundled-*`` pricing tables in the ledger (UTC anchor month).""" + from flightdeck.bundled_pricing_age import ( + bundled_pricing_age_days, + bundled_pricing_anchor_date, + is_flightdeck_bundled_pricing_version, + pricing_stale_check_date, + ) + + if max_age_days < 0: + raise click.ClickException("--max-age-days must be non-negative") + + cfg = load_config() + storage = storage_from_config(cfg) + storage.migrate() + + today = pricing_stale_check_date() + bundled = [ + v + for v in storage.list_distinct_pricing_versions() + if is_flightdeck_bundled_pricing_version(v) + ] + if not bundled: + click.echo("No flightdeck-bundled-* pricing tables in the ledger.") + return + + stale_any = False + for v in bundled: + anchor = bundled_pricing_anchor_date(v) + age = bundled_pricing_age_days(v, today=today) + assert anchor is not None and age is not None + if age > max_age_days: + stale_any = True + click.echo( + f"STALE {v} (anchor {anchor.isoformat()}, ~{age} days old; max {max_age_days})", + err=True, + ) + else: + click.echo(f"OK {v} (~{age} days old; max {max_age_days})") + + if fail and stale_any: + raise ClickExit(1) + + @cli.group() def policy() -> None: """Set and view promotion policy.""" diff --git a/src/flightdeck/operations.py b/src/flightdeck/operations.py index f1986d7..6a8abf8 100644 --- a/src/flightdeck/operations.py +++ b/src/flightdeck/operations.py @@ -9,6 +9,7 @@ import yaml from pydantic import ValidationError +from flightdeck.bundled_pricing_age import bundled_pricing_stale_warning from flightdeck.catalog import ( catalog_tariff_as_table, load_pricing_catalog, @@ -311,6 +312,16 @@ def compute_diff( ) ) + _warned_versions: set[str] = set() + for ref, role in ((base_ref, "baseline"), (cand_ref, "candidate")): + pv = ref.pricing_version + if pv in _warned_versions: + continue + _warned_versions.add(pv) + stale = bundled_pricing_stale_warning(pv, role=role) + if stale: + pricing_warnings.append(stale) + catalog_enabled = False catalog_version: str | None = None baseline_catalog_slot_id: str | None = None diff --git a/src/flightdeck/sdk/client.py b/src/flightdeck/sdk/client.py index d883ac1..062f4d2 100644 --- a/src/flightdeck/sdk/client.py +++ b/src/flightdeck/sdk/client.py @@ -1,12 +1,25 @@ from __future__ import annotations -import asyncio -import time from typing import Any, Iterable import httpx from flightdeck.models import RunEvent +from flightdeck.sdk.http_common import ( + ClientHttpCore, + HttpRetryPolicy, + actions_params, + async_request_with_retry, + diff_request_body, + events_ingest_json, + export_headers_from_response, + promote_confirm_body, + promote_like_body, + promotion_requests_params, + rollback_body, + runs_list_params, + sync_request_with_retry, +) class FlightdeckClient: @@ -20,24 +33,29 @@ def __init__( api_token: str | None = None, client: httpx.Client | None = None, ) -> None: - self._base_url = base_url.rstrip("/") + self._core = ClientHttpCore(base_url, api_token) self._owns_client = client is None self._client = client or httpx.Client(timeout=timeout_s) - self._max_retries = max(0, max_retries) - self._retry_backoff_s = max(0.0, retry_backoff_s) - self._api_token = api_token + self._retry = HttpRetryPolicy(max(0, max_retries), max(0.0, retry_backoff_s)) def close(self) -> None: if self._owns_client: self._client.close() def _auth_headers(self) -> dict[str, str]: - if self._api_token: - return {"Authorization": f"Bearer {self._api_token}"} - return {} + return self._core.auth_headers() def _json_headers(self) -> dict[str, str]: - return {"Content-Type": "application/json", **self._auth_headers()} + return self._core.json_headers() + + def _request_with_retry(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + return sync_request_with_retry( + self._client, + url=self._core.abs_url(path), + policy=self._retry, + method=method, + **kwargs, + ) def health(self) -> dict[str, Any]: resp = self._request_with_retry("GET", "/health", headers=self._auth_headers() or None) @@ -66,11 +84,7 @@ def list_actions( environment: str | None = None, limit: int = 50, ) -> dict[str, Any]: - params: dict[str, str | int] = {"limit": limit} - if agent_id is not None: - params["agent"] = agent_id - if environment is not None: - params["env"] = environment + params = actions_params(agent_id=agent_id, environment=environment, limit=limit) resp = self._request_with_retry( "GET", "/v1/actions", @@ -90,14 +104,14 @@ def post_diff( tenant_id: str | None = None, task_id: str | None = None, ) -> dict[str, Any]: - body: dict[str, Any] = { - "baseline_release_id": baseline_release_id, - "candidate_release_id": candidate_release_id, - "window": window, - "environment": environment, - "tenant_id": tenant_id, - "task_id": task_id, - } + body = diff_request_body( + baseline_release_id=baseline_release_id, + candidate_release_id=candidate_release_id, + window=window, + environment=environment, + tenant_id=tenant_id, + task_id=task_id, + ) resp = self._request_with_retry("POST", "/v1/diff", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -111,13 +125,13 @@ def post_promote( reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "release_id": release_id, - "environment": environment, - "window": window, - "reason": reason, - "actor": actor, - } + body = promote_like_body( + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) resp = self._request_with_retry("POST", "/v1/promote", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -131,13 +145,13 @@ def post_promote_request( reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "release_id": release_id, - "environment": environment, - "window": window, - "reason": reason, - "actor": actor, - } + body = promote_like_body( + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) resp = self._request_with_retry("POST", "/v1/promote/request", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -149,11 +163,7 @@ def post_promote_confirm( approval_reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "request_id": request_id, - "approval_reason": approval_reason, - "actor": actor, - } + body = promote_confirm_body(request_id=request_id, approval_reason=approval_reason, actor=actor) resp = self._request_with_retry("POST", "/v1/promote/confirm", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -164,9 +174,7 @@ def list_promotion_requests( status: str | None = None, limit: int = 50, ) -> dict[str, Any]: - params: dict[str, str | int] = {"limit": limit} - if status is not None: - params["status"] = status + params = promotion_requests_params(status=status, limit=limit) resp = self._request_with_retry( "GET", "/v1/promotion-requests", @@ -190,24 +198,18 @@ def list_runs( offset: int = 0, limit: int = 100, ) -> dict[str, Any]: - params: dict[str, str | int] = { - "release_id": release_id, - "window": window, - "limit": limit, - "offset": offset, - } - if environment is not None: - params["environment"] = environment - if tenant_id is not None: - params["tenant_id"] = tenant_id - if task_id is not None: - params["task_id"] = task_id - if trace_id is not None: - params["trace_id"] = trace_id - if session_id is not None: - params["session_id"] = session_id - if span_id is not None: - params["span_id"] = span_id + params = runs_list_params( + release_id=release_id, + window=window, + environment=environment, + tenant_id=tenant_id, + task_id=task_id, + trace_id=trace_id, + session_id=session_id, + span_id=span_id, + offset=offset, + limit=limit, + ) resp = self._request_with_retry( "GET", "/v1/runs", @@ -232,24 +234,18 @@ def fetch_runs_export_ndjson( limit: int = 500, ) -> tuple[bytes, dict[str, str]]: """GET /v1/runs/export — returns raw NDJSON body and selected response headers.""" - params: dict[str, str | int] = { - "release_id": release_id, - "window": window, - "limit": limit, - "offset": offset, - } - if environment is not None: - params["environment"] = environment - if tenant_id is not None: - params["tenant_id"] = tenant_id - if task_id is not None: - params["task_id"] = task_id - if trace_id is not None: - params["trace_id"] = trace_id - if session_id is not None: - params["session_id"] = session_id - if span_id is not None: - params["span_id"] = span_id + params = runs_list_params( + release_id=release_id, + window=window, + environment=environment, + tenant_id=tenant_id, + task_id=task_id, + trace_id=trace_id, + session_id=session_id, + span_id=span_id, + offset=offset, + limit=limit, + ) resp = self._request_with_retry( "GET", "/v1/runs/export", @@ -257,17 +253,7 @@ def fetch_runs_export_ndjson( headers=self._auth_headers() or None, ) resp.raise_for_status() - hdrs = { - k: resp.headers[k] - for k in ( - "X-Flightdeck-Matched-Total", - "X-Flightdeck-Returned", - "X-Flightdeck-Offset", - "X-Flightdeck-Truncated", - ) - if k in resp.headers - } - return (resp.content, hdrs) + return (resp.content, export_headers_from_response(resp)) def post_rollback( self, @@ -278,20 +264,20 @@ def post_rollback( reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "release_id": release_id, - "environment": environment, - "window": window, - "reason": reason, - "actor": actor, - } + body = rollback_body( + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) resp = self._request_with_retry("POST", "/v1/rollback", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() def ingest_run_events(self, events: Iterable[RunEvent]) -> int: - payload = {"events": [e.model_dump(mode="json") for e in events]} - if not payload["events"]: + payload = events_ingest_json(events) + if payload is None: return 0 resp = self._request_with_retry("POST", "/v1/events", json=payload, headers=self._json_headers()) resp.raise_for_status() @@ -312,19 +298,6 @@ def ingest_run_events_batch(self, events: Iterable[RunEvent], *, chunk_size: int total += self.ingest_run_events(chunk) return total - def _request_with_retry(self, method: str, path: str, **kwargs) -> httpx.Response: - last_exc: httpx.RequestError | None = None - for attempt in range(self._max_retries + 1): - try: - return self._client.request(method, f"{self._base_url}{path}", **kwargs) - except httpx.RequestError as exc: - last_exc = exc - if attempt >= self._max_retries: - raise - time.sleep(self._retry_backoff_s * (2**attempt)) - assert last_exc is not None - raise last_exc - class AsyncFlightdeckClient: def __init__( @@ -337,24 +310,29 @@ def __init__( api_token: str | None = None, client: httpx.AsyncClient | None = None, ) -> None: - self._base_url = base_url.rstrip("/") + self._core = ClientHttpCore(base_url, api_token) self._owns_client = client is None self._client = client or httpx.AsyncClient(timeout=timeout_s) - self._max_retries = max(0, max_retries) - self._retry_backoff_s = max(0.0, retry_backoff_s) - self._api_token = api_token + self._retry = HttpRetryPolicy(max(0, max_retries), max(0.0, retry_backoff_s)) async def aclose(self) -> None: if self._owns_client: await self._client.aclose() def _auth_headers(self) -> dict[str, str]: - if self._api_token: - return {"Authorization": f"Bearer {self._api_token}"} - return {} + return self._core.auth_headers() def _json_headers(self) -> dict[str, str]: - return {"Content-Type": "application/json", **self._auth_headers()} + return self._core.json_headers() + + async def _request_with_retry(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + return await async_request_with_retry( + self._client, + url=self._core.abs_url(path), + policy=self._retry, + method=method, + **kwargs, + ) async def health(self) -> dict[str, Any]: resp = await self._request_with_retry("GET", "/health", headers=self._auth_headers() or None) @@ -383,11 +361,7 @@ async def list_actions( environment: str | None = None, limit: int = 50, ) -> dict[str, Any]: - params: dict[str, str | int] = {"limit": limit} - if agent_id is not None: - params["agent"] = agent_id - if environment is not None: - params["env"] = environment + params = actions_params(agent_id=agent_id, environment=environment, limit=limit) resp = await self._request_with_retry( "GET", "/v1/actions", @@ -407,14 +381,14 @@ async def post_diff( tenant_id: str | None = None, task_id: str | None = None, ) -> dict[str, Any]: - body: dict[str, Any] = { - "baseline_release_id": baseline_release_id, - "candidate_release_id": candidate_release_id, - "window": window, - "environment": environment, - "tenant_id": tenant_id, - "task_id": task_id, - } + body = diff_request_body( + baseline_release_id=baseline_release_id, + candidate_release_id=candidate_release_id, + window=window, + environment=environment, + tenant_id=tenant_id, + task_id=task_id, + ) resp = await self._request_with_retry("POST", "/v1/diff", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -428,13 +402,13 @@ async def post_promote( reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "release_id": release_id, - "environment": environment, - "window": window, - "reason": reason, - "actor": actor, - } + body = promote_like_body( + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) resp = await self._request_with_retry("POST", "/v1/promote", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -448,13 +422,13 @@ async def post_promote_request( reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "release_id": release_id, - "environment": environment, - "window": window, - "reason": reason, - "actor": actor, - } + body = promote_like_body( + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) resp = await self._request_with_retry("POST", "/v1/promote/request", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -466,11 +440,7 @@ async def post_promote_confirm( approval_reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "request_id": request_id, - "approval_reason": approval_reason, - "actor": actor, - } + body = promote_confirm_body(request_id=request_id, approval_reason=approval_reason, actor=actor) resp = await self._request_with_retry("POST", "/v1/promote/confirm", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() @@ -481,9 +451,7 @@ async def list_promotion_requests( status: str | None = None, limit: int = 50, ) -> dict[str, Any]: - params: dict[str, str | int] = {"limit": limit} - if status is not None: - params["status"] = status + params = promotion_requests_params(status=status, limit=limit) resp = await self._request_with_retry( "GET", "/v1/promotion-requests", @@ -507,24 +475,18 @@ async def list_runs( offset: int = 0, limit: int = 100, ) -> dict[str, Any]: - params: dict[str, str | int] = { - "release_id": release_id, - "window": window, - "limit": limit, - "offset": offset, - } - if environment is not None: - params["environment"] = environment - if tenant_id is not None: - params["tenant_id"] = tenant_id - if task_id is not None: - params["task_id"] = task_id - if trace_id is not None: - params["trace_id"] = trace_id - if session_id is not None: - params["session_id"] = session_id - if span_id is not None: - params["span_id"] = span_id + params = runs_list_params( + release_id=release_id, + window=window, + environment=environment, + tenant_id=tenant_id, + task_id=task_id, + trace_id=trace_id, + session_id=session_id, + span_id=span_id, + offset=offset, + limit=limit, + ) resp = await self._request_with_retry( "GET", "/v1/runs", @@ -548,24 +510,18 @@ async def fetch_runs_export_ndjson( offset: int = 0, limit: int = 500, ) -> tuple[bytes, dict[str, str]]: - params: dict[str, str | int] = { - "release_id": release_id, - "window": window, - "limit": limit, - "offset": offset, - } - if environment is not None: - params["environment"] = environment - if tenant_id is not None: - params["tenant_id"] = tenant_id - if task_id is not None: - params["task_id"] = task_id - if trace_id is not None: - params["trace_id"] = trace_id - if session_id is not None: - params["session_id"] = session_id - if span_id is not None: - params["span_id"] = span_id + params = runs_list_params( + release_id=release_id, + window=window, + environment=environment, + tenant_id=tenant_id, + task_id=task_id, + trace_id=trace_id, + session_id=session_id, + span_id=span_id, + offset=offset, + limit=limit, + ) resp = await self._request_with_retry( "GET", "/v1/runs/export", @@ -573,17 +529,7 @@ async def fetch_runs_export_ndjson( headers=self._auth_headers() or None, ) resp.raise_for_status() - hdrs = { - k: resp.headers[k] - for k in ( - "X-Flightdeck-Matched-Total", - "X-Flightdeck-Returned", - "X-Flightdeck-Offset", - "X-Flightdeck-Truncated", - ) - if k in resp.headers - } - return (resp.content, hdrs) + return (resp.content, export_headers_from_response(resp)) async def post_rollback( self, @@ -594,20 +540,20 @@ async def post_rollback( reason: str, actor: str = "sdk", ) -> dict[str, Any]: - body = { - "release_id": release_id, - "environment": environment, - "window": window, - "reason": reason, - "actor": actor, - } + body = rollback_body( + release_id=release_id, + environment=environment, + window=window, + reason=reason, + actor=actor, + ) resp = await self._request_with_retry("POST", "/v1/rollback", json=body, headers=self._json_headers()) resp.raise_for_status() return resp.json() async def ingest_run_events(self, events: Iterable[RunEvent]) -> int: - payload = {"events": [e.model_dump(mode="json") for e in events]} - if not payload["events"]: + payload = events_ingest_json(events) + if payload is None: return 0 resp = await self._request_with_retry("POST", "/v1/events", json=payload, headers=self._json_headers()) resp.raise_for_status() @@ -627,16 +573,3 @@ async def ingest_run_events_batch(self, events: Iterable[RunEvent], *, chunk_siz if chunk: total += await self.ingest_run_events(chunk) return total - - async def _request_with_retry(self, method: str, path: str, **kwargs) -> httpx.Response: - last_exc: httpx.RequestError | None = None - for attempt in range(self._max_retries + 1): - try: - return await self._client.request(method, f"{self._base_url}{path}", **kwargs) - except httpx.RequestError as exc: - last_exc = exc - if attempt >= self._max_retries: - raise - await asyncio.sleep(self._retry_backoff_s * (2**attempt)) - assert last_exc is not None - raise last_exc diff --git a/src/flightdeck/storage.py b/src/flightdeck/storage.py index 28751a0..90e6b8a 100644 --- a/src/flightdeck/storage.py +++ b/src/flightdeck/storage.py @@ -536,6 +536,14 @@ def list_pricing_versions(self, provider: str) -> list[str]: ).fetchall() return [str(r["pricing_version"]) for r in rows] + def list_distinct_pricing_versions(self) -> list[str]: + """All distinct ``pricing_version`` values present in the ledger.""" + with self.connect() as conn: + rows = conn.execute( + "SELECT DISTINCT pricing_version FROM pricing_tables ORDER BY pricing_version", + ).fetchall() + return [str(r["pricing_version"]) for r in rows] + def insert_promotion_request(self, record: PromotionRequestRecord) -> None: with self.transaction() as conn: conn.execute( From 09a41e4747aa1d50ea7c8289ffe0fe72e83c5d95 Mon Sep 17 00:00:00 2001 From: zendaya Date: Sun, 3 May 2026 20:14:28 +0200 Subject: [PATCH 2/2] Update ruff dependency version constraint in uv.lock to allow for minor updates. --- src/flightdeck/bundled_pricing_age.py | 65 +++++++ src/flightdeck/integrations/telemetry.py | 52 ++++++ src/flightdeck/sdk/http_common.py | 214 +++++++++++++++++++++++ tests/test_bundled_pricing_age.py | 133 ++++++++++++++ tests/test_integrations_telemetry.py | 25 +++ tests/test_sdk_client_parity.py | 154 ++++++++++++++++ tests/test_sdk_http_common.py | 88 ++++++++++ uv.lock | 2 +- 8 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 src/flightdeck/bundled_pricing_age.py create mode 100644 src/flightdeck/integrations/telemetry.py create mode 100644 src/flightdeck/sdk/http_common.py create mode 100644 tests/test_bundled_pricing_age.py create mode 100644 tests/test_integrations_telemetry.py create mode 100644 tests/test_sdk_client_parity.py create mode 100644 tests/test_sdk_http_common.py diff --git a/src/flightdeck/bundled_pricing_age.py b/src/flightdeck/bundled_pricing_age.py new file mode 100644 index 0000000..2df1a01 --- /dev/null +++ b/src/flightdeck/bundled_pricing_age.py @@ -0,0 +1,65 @@ +"""Age and staleness helpers for ``flightdeck-bundled-YYYY-MM`` pricing snapshots.""" + +from __future__ import annotations + +import re +from datetime import date, datetime, timezone + +# Bundled snapshot ids use the first day of the labeled month as the freshness anchor. +BUNDLED_PRICING_VERSION_RE = re.compile(r"^flightdeck-bundled-(\d{4})-(\d{2})$") + +# Default max age before CLI / diff warn (days since anchor). +DEFAULT_BUNDLED_PRICING_MAX_AGE_DAYS = 90 + + +def pricing_stale_check_date() -> date: + """UTC date used for staleness checks (patch in tests).""" + return datetime.now(timezone.utc).date() + + +def is_flightdeck_bundled_pricing_version(pricing_version: str) -> bool: + return bundled_pricing_anchor_date(pricing_version) is not None + + +def bundled_pricing_anchor_date(pricing_version: str) -> date | None: + m = BUNDLED_PRICING_VERSION_RE.match(pricing_version.strip()) + if not m: + return None + year, month = int(m.group(1)), int(m.group(2)) + if not (1 <= month <= 12): + return None + return date(year, month, 1) + + +def bundled_pricing_age_days(pricing_version: str, *, today: date) -> int | None: + anchor = bundled_pricing_anchor_date(pricing_version) + if anchor is None: + return None + return (today - anchor).days + + +def bundled_pricing_stale_warning( + pricing_version: str, + *, + today: date | None = None, + max_age_days: int = DEFAULT_BUNDLED_PRICING_MAX_AGE_DAYS, + role: str | None = None, +) -> str | None: + """ + Return a human-readable warning if this bundled snapshot is older than ``max_age_days``. + + ``role`` is optional ("baseline" / "candidate") for diff copy. + """ + anchor = bundled_pricing_anchor_date(pricing_version) + if anchor is None: + return None + day = today if today is not None else pricing_stale_check_date() + age = (day - anchor).days + if age <= max_age_days: + return None + prefix = f"{role} " if role else "" + return ( + f"{prefix}pricing_version {pricing_version!r} is a FlightDeck bundled snapshot from " + f"{anchor.isoformat()} (~{age} days old). List prices drift; run `flightdeck pricing import` " + f"with authoritative YAML or upgrade to a newer `flightdeck-ai` minor for refreshed bundled tables." + ) diff --git a/src/flightdeck/integrations/telemetry.py b/src/flightdeck/integrations/telemetry.py new file mode 100644 index 0000000..04c1186 --- /dev/null +++ b/src/flightdeck/integrations/telemetry.py @@ -0,0 +1,52 @@ +"""Opt-in OpenTelemetry tracer registration (``telemetry`` extra). + +Call :func:`configure_otel_tracing` once at process startup to export spans to **your** +OTLP endpoint (not a FlightDeck-hosted backend). Standard ``OTEL_*`` and +``OTEL_EXPORTER_OTLP_*`` environment variables are honored by the underlying exporter. +""" + +from __future__ import annotations + +import logging +import os + +_log = logging.getLogger(__name__) +_configured = False + + +def configure_otel_tracing(*, force: bool = False) -> bool: + """ + Install a :class:`~opentelemetry.sdk.trace.TracerProvider` with an OTLP HTTP + span exporter and :class:`~opentelemetry.sdk.trace.export.BatchSpanProcessor`. + + Returns ``True`` when this call installed (or reinstalled) the provider, ``False`` + if a provider was already installed and ``force`` is ``False``. + + Requires the distribution extra ``telemetry`` (``opentelemetry-sdk`` and + ``opentelemetry-exporter-otlp``). + """ + global _configured + if _configured and not force: + return False + + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.resources import SERVICE_NAME, Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + except ImportError as exc: # pragma: no cover - exercised when extra missing + raise ImportError( + "flightdeck.integrations.telemetry requires the 'telemetry' extra " + "(for example: uv sync --extra telemetry or pip install 'flightdeck-ai[telemetry]')." + ) from exc + + service_name = os.environ.get("OTEL_SERVICE_NAME", "flightdeck-python").strip() or "flightdeck-python" + resource = Resource.create({SERVICE_NAME: service_name}) + provider = TracerProvider(resource=resource) + exporter = OTLPSpanExporter() + provider.add_span_processor(BatchSpanProcessor(exporter)) + trace.set_tracer_provider(provider) + _configured = True + _log.debug("OpenTelemetry TracerProvider configured for OTLP HTTP export.") + return True diff --git a/src/flightdeck/sdk/http_common.py b/src/flightdeck/sdk/http_common.py new file mode 100644 index 0000000..8147fa9 --- /dev/null +++ b/src/flightdeck/sdk/http_common.py @@ -0,0 +1,214 @@ +"""Shared HTTP helpers for sync and async FlightDeck SDK clients.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + +import httpx + +from flightdeck.models import RunEvent + + +@dataclass(frozen=True) +class HttpRetryPolicy: + """Retry loop parameters (sync uses ``time.sleep``, async ``asyncio.sleep``).""" + + max_retries: int + backoff_s: float + + +class ClientHttpCore: + """URL and header helpers shared by ``FlightdeckClient`` and ``AsyncFlightdeckClient``.""" + + __slots__ = ("_api_token", "_base_url") + + def __init__(self, base_url: str, api_token: str | None) -> None: + self._base_url = base_url.rstrip("/") + self._api_token = api_token + + def abs_url(self, path: str) -> str: + return f"{self._base_url}{path}" + + def auth_headers(self) -> dict[str, str]: + if self._api_token: + return {"Authorization": f"Bearer {self._api_token}"} + return {} + + def json_headers(self) -> dict[str, str]: + return {"Content-Type": "application/json", **self.auth_headers()} + + +def actions_params(*, agent_id: str | None, environment: str | None, limit: int) -> dict[str, str | int]: + params: dict[str, str | int] = {"limit": limit} + if agent_id is not None: + params["agent"] = agent_id + if environment is not None: + params["env"] = environment + return params + + +def diff_request_body( + *, + baseline_release_id: str, + candidate_release_id: str, + window: str, + environment: str | None, + tenant_id: str | None, + task_id: str | None, +) -> dict[str, Any]: + return { + "baseline_release_id": baseline_release_id, + "candidate_release_id": candidate_release_id, + "window": window, + "environment": environment, + "tenant_id": tenant_id, + "task_id": task_id, + } + + +def promote_like_body( + *, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str, +) -> dict[str, Any]: + return { + "release_id": release_id, + "environment": environment, + "window": window, + "reason": reason, + "actor": actor, + } + + +def promote_confirm_body(*, request_id: str, approval_reason: str, actor: str) -> dict[str, Any]: + return { + "request_id": request_id, + "approval_reason": approval_reason, + "actor": actor, + } + + +def promotion_requests_params(*, status: str | None, limit: int) -> dict[str, str | int]: + params: dict[str, str | int] = {"limit": limit} + if status is not None: + params["status"] = status + return params + + +def runs_list_params( + *, + release_id: str, + window: str, + environment: str | None, + tenant_id: str | None, + task_id: str | None, + trace_id: str | None, + session_id: str | None, + span_id: str | None, + offset: int, + limit: int, +) -> dict[str, str | int]: + params: dict[str, str | int] = { + "release_id": release_id, + "window": window, + "limit": limit, + "offset": offset, + } + if environment is not None: + params["environment"] = environment + if tenant_id is not None: + params["tenant_id"] = tenant_id + if task_id is not None: + params["task_id"] = task_id + if trace_id is not None: + params["trace_id"] = trace_id + if session_id is not None: + params["session_id"] = session_id + if span_id is not None: + params["span_id"] = span_id + return params + + +def rollback_body( + *, + release_id: str, + environment: str, + window: str, + reason: str, + actor: str, +) -> dict[str, Any]: + return { + "release_id": release_id, + "environment": environment, + "window": window, + "reason": reason, + "actor": actor, + } + + +def events_ingest_json(events: Iterable[RunEvent]) -> dict[str, Any] | None: + payload = {"events": [e.model_dump(mode="json") for e in events]} + if not payload["events"]: + return None + return payload + + +EXPORT_HEADER_KEYS = ( + "X-Flightdeck-Matched-Total", + "X-Flightdeck-Returned", + "X-Flightdeck-Offset", + "X-Flightdeck-Truncated", +) + + +def export_headers_from_response(resp: httpx.Response) -> dict[str, str]: + return {k: resp.headers[k] for k in EXPORT_HEADER_KEYS if k in resp.headers} + + +def sync_request_with_retry( + client: httpx.Client, + *, + url: str, + policy: HttpRetryPolicy, + method: str, + **kwargs: Any, +) -> httpx.Response: + last_exc: httpx.RequestError | None = None + for attempt in range(policy.max_retries + 1): + try: + return client.request(method, url, **kwargs) + except httpx.RequestError as exc: + last_exc = exc + if attempt >= policy.max_retries: + raise + time.sleep(policy.backoff_s * (2**attempt)) + assert last_exc is not None + raise last_exc + + +async def async_request_with_retry( + client: httpx.AsyncClient, + *, + url: str, + policy: HttpRetryPolicy, + method: str, + **kwargs: Any, +) -> httpx.Response: + last_exc: httpx.RequestError | None = None + for attempt in range(policy.max_retries + 1): + try: + return await client.request(method, url, **kwargs) + except httpx.RequestError as exc: + last_exc = exc + if attempt >= policy.max_retries: + raise + await asyncio.sleep(policy.backoff_s * (2**attempt)) + assert last_exc is not None + raise last_exc diff --git a/tests/test_bundled_pricing_age.py b/tests/test_bundled_pricing_age.py new file mode 100644 index 0000000..1702bf2 --- /dev/null +++ b/tests/test_bundled_pricing_age.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import date + +from click.testing import CliRunner + +from flightdeck.bundled_pricing_age import ( + bundled_pricing_age_days, + bundled_pricing_anchor_date, + bundled_pricing_stale_warning, + is_flightdeck_bundled_pricing_version, +) +from flightdeck.cli.main import cli +from flightdeck.config import load_config +from flightdeck.operations import compute_diff +from flightdeck.storage import storage_from_config + +from tests.test_spine import write_policy, write_release + + +def test_bundled_pricing_anchor_and_age() -> None: + assert bundled_pricing_anchor_date("flightdeck-bundled-2026-05") == date(2026, 5, 1) + assert bundled_pricing_anchor_date("openai-2026-04-30") is None + assert is_flightdeck_bundled_pricing_version("flightdeck-bundled-2026-05") is True + assert is_flightdeck_bundled_pricing_version("custom-v1") is False + age = bundled_pricing_age_days("flightdeck-bundled-2026-05", today=date(2026, 5, 3)) + assert age == 2 + + +def test_stale_warning_when_old() -> None: + w = bundled_pricing_stale_warning( + "flightdeck-bundled-2026-05", + today=date(2026, 9, 1), + max_age_days=90, + role="baseline", + ) + assert w is not None + assert "baseline" in w + assert "flightdeck-bundled-2026-05" in w + + +def test_no_stale_warning_when_fresh() -> None: + assert ( + bundled_pricing_stale_warning( + "flightdeck-bundled-2026-05", + today=date(2026, 5, 20), + max_age_days=90, + ) + is None + ) + + +def test_pricing_check_cli_ok_and_stale_exit(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + + monkeypatch.setattr( + "flightdeck.bundled_pricing_age.pricing_stale_check_date", + lambda: date(2026, 5, 10), + ) + r_ok = runner.invoke(cli, ["pricing", "check", "--max-age-days", "90"]) + assert r_ok.exit_code == 0 + assert "OK" in r_ok.output + assert "flightdeck-bundled-2026-05" in r_ok.output + + monkeypatch.setattr( + "flightdeck.bundled_pricing_age.pricing_stale_check_date", + lambda: date(2026, 9, 1), + ) + r_warn = runner.invoke(cli, ["pricing", "check", "--max-age-days", "90"]) + assert r_warn.exit_code == 0 + assert "STALE" in r_warn.output + + r_fail = runner.invoke(cli, ["pricing", "check", "--max-age-days", "90", "--fail"]) + assert r_fail.exit_code == 1 + + +def test_compute_diff_single_stale_warning_same_bundled_version(tmp_path, monkeypatch) -> None: + """Baseline and candidate sharing one bundled version emit one staleness warning.""" + monkeypatch.chdir(tmp_path) + runner = CliRunner() + assert runner.invoke(cli, ["init"]).exit_code == 0 + + policy = write_policy( + tmp_path, + min_candidate_runs=0, + min_baseline_runs=0, + min_low_runs=0, + require_high_diff_confidence=False, + ) + assert runner.invoke(cli, ["policy", "set", str(policy)]).exit_code == 0 + + r1 = write_release( + tmp_path, + agent_id="agent_x", + version="1", + pricing_provider="openai", + pricing_version="flightdeck-bundled-2026-05", + model="gpt-4o-mini", + ) + r2 = write_release( + tmp_path, + agent_id="agent_x", + version="2", + pricing_provider="openai", + pricing_version="flightdeck-bundled-2026-05", + model="gpt-4o-mini", + ) + rel1 = runner.invoke(cli, ["release", "register", str(r1)]).output.strip() + rel2 = runner.invoke(cli, ["release", "register", str(r2)]).output.strip() + + monkeypatch.setattr( + "flightdeck.bundled_pricing_age.pricing_stale_check_date", + lambda: date(2026, 9, 1), + ) + + cfg = load_config() + storage = storage_from_config(cfg) + storage.migrate() + out = compute_diff( + cfg=cfg, + storage=storage, + baseline_release_id=rel1, + candidate_release_id=rel2, + window="7d", + environment="local", + tenant_id=None, + task_id=None, + ) + stale_msgs = [w for w in out.pricing_warnings if "bundled snapshot" in w] + assert len(stale_msgs) == 1 + assert "flightdeck-bundled-2026-05" in stale_msgs[0] diff --git a/tests/test_integrations_telemetry.py b/tests/test_integrations_telemetry.py new file mode 100644 index 0000000..78884d8 --- /dev/null +++ b/tests/test_integrations_telemetry.py @@ -0,0 +1,25 @@ +"""Optional OpenTelemetry wiring (``telemetry`` extra).""" + +from __future__ import annotations + +import os + +import pytest + + +def test_configure_otel_tracing_import_and_idempotent() -> None: + pytest.importorskip("opentelemetry.sdk") + pytest.importorskip("opentelemetry.exporter.otlp.proto.http.trace_exporter") + + from opentelemetry import trace + + from flightdeck.integrations import telemetry as tel + + prev = trace.get_tracer_provider() + os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9/v1/traces") + try: + assert tel.configure_otel_tracing(force=True) is True + assert tel.configure_otel_tracing() is False + finally: + trace.set_tracer_provider(prev) + tel._configured = False # type: ignore[attr-defined] diff --git a/tests/test_sdk_client_parity.py b/tests/test_sdk_client_parity.py new file mode 100644 index 0000000..8668ae6 --- /dev/null +++ b/tests/test_sdk_client_parity.py @@ -0,0 +1,154 @@ +"""Sync vs async SDK clients must issue identical HTTP shapes (shared ``http_common``).""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, timezone + +import httpx + +from flightdeck.models import RunEvent, RunEventModelUsage, RunEventUsage +from flightdeck.sdk.client import AsyncFlightdeckClient, FlightdeckClient + + +def _snap(request: httpx.Request) -> tuple[str, str, tuple[tuple[str, str], ...], str]: + q = tuple(sorted(request.url.params.multi_items())) + body = request.content.decode("utf-8") if request.content else "" + return (request.method, request.url.path, q, body) + + +def _event() -> RunEvent: + now = datetime.now(tz=timezone.utc) + return RunEvent( + timestamp=now, + agent_id="a", + release_id="r", + run_id="run_parity", + tenant_id="t", + task_id="k", + environment="local", + usage=RunEventUsage( + model=RunEventModelUsage( + provider="openai", + model="gpt-4.1-mini", + input_tokens=1, + output_tokens=1, + ) + ), + ) + + +def test_parity_list_runs_params_and_headers() -> None: + sync_captured: list[tuple[str, str, tuple[tuple[str, str], ...], str]] = [] + + def sync_handler(request: httpx.Request) -> httpx.Response: + sync_captured.append(_snap(request)) + return httpx.Response(200, json={"runs": []}) + + transport = httpx.MockTransport(sync_handler) + with httpx.Client(transport=transport, base_url="http://fd.test") as http: + c = FlightdeckClient("http://fd.test", client=http, api_token="abc") + c.list_runs( + release_id="rel1", + window="7d", + environment="local", + tenant_id="t1", + task_id=None, + trace_id="tr1", + session_id=None, + span_id=None, + offset=2, + limit=50, + ) + c.close() + + async_captured: list[tuple[str, str, tuple[tuple[str, str], ...], str]] = [] + + def async_handler(request: httpx.Request) -> httpx.Response: + async_captured.append(_snap(request)) + return httpx.Response(200, json={"runs": []}) + + async def _go() -> None: + t = httpx.MockTransport(async_handler) + async with httpx.AsyncClient(transport=t, base_url="http://fd.test") as http: + ac = AsyncFlightdeckClient("http://fd.test", client=http, api_token="abc") + await ac.list_runs( + release_id="rel1", + window="7d", + environment="local", + tenant_id="t1", + task_id=None, + trace_id="tr1", + session_id=None, + span_id=None, + offset=2, + limit=50, + ) + await ac.aclose() + + asyncio.run(_go()) + assert sync_captured == async_captured + method, path, query, _body = sync_captured[0] + assert method == "GET" + assert path == "/v1/runs" + assert ("trace_id", "tr1") in query + + +def test_parity_post_diff_json_body() -> None: + sync_captured: list[str] = [] + + def sh(request: httpx.Request) -> httpx.Response: + sync_captured.append(request.content.decode("utf-8")) + return httpx.Response(200, json={}) + + with httpx.Client(transport=httpx.MockTransport(sh), base_url="http://fd.test") as http: + FlightdeckClient("http://fd.test", client=http).post_diff( + baseline_release_id="b1", + candidate_release_id="c1", + window="24h", + tenant_id="tn", + ) + + async_captured: list[str] = [] + + def ah(request: httpx.Request) -> httpx.Response: + async_captured.append(request.content.decode("utf-8")) + return httpx.Response(200, json={}) + + async def _go() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(ah), base_url="http://fd.test") as http: + await AsyncFlightdeckClient("http://fd.test", client=http).post_diff( + baseline_release_id="b1", + candidate_release_id="c1", + window="24h", + tenant_id="tn", + ) + + asyncio.run(_go()) + assert json.loads(sync_captured[0]) == json.loads(async_captured[0]) + + +def test_parity_ingest_events_payload() -> None: + ev = _event() + sync_body: list[dict] = [] + + def sh(request: httpx.Request) -> httpx.Response: + sync_body.append(json.loads(request.content.decode("utf-8"))) + return httpx.Response(200, json={"inserted": 1}) + + with httpx.Client(transport=httpx.MockTransport(sh), base_url="http://fd.test") as http: + FlightdeckClient("http://fd.test", client=http).ingest_run_events([ev]) + + async_body: list[dict] = [] + + def ah(request: httpx.Request) -> httpx.Response: + async_body.append(json.loads(request.content.decode("utf-8"))) + return httpx.Response(200, json={"inserted": 1}) + + async def _go() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(ah), base_url="http://fd.test") as http: + await AsyncFlightdeckClient("http://fd.test", client=http).ingest_run_events([ev]) + + asyncio.run(_go()) + assert sync_body == async_body diff --git a/tests/test_sdk_http_common.py b/tests/test_sdk_http_common.py new file mode 100644 index 0000000..58faffe --- /dev/null +++ b/tests/test_sdk_http_common.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone + +from flightdeck.models import RunEvent, RunEventModelUsage, RunEventUsage +from flightdeck.sdk.http_common import ( + ClientHttpCore, + diff_request_body, + events_ingest_json, + promote_confirm_body, + runs_list_params, +) + + +def test_client_http_core_headers() -> None: + c = ClientHttpCore("http://example.com/", None) + assert c.abs_url("/v1/health") == "http://example.com/v1/health" + assert c.auth_headers() == {} + assert c.json_headers() == {"Content-Type": "application/json"} + + t = ClientHttpCore("http://x", "tok") + assert t.auth_headers() == {"Authorization": "Bearer tok"} + assert t.json_headers()["Authorization"] == "Bearer tok" + + +def test_diff_request_body_includes_nones() -> None: + b = diff_request_body( + baseline_release_id="a", + candidate_release_id="b", + window="7d", + environment=None, + tenant_id=None, + task_id=None, + ) + assert b["environment"] is None + assert b["tenant_id"] is None + + +def test_events_ingest_json_round_trip() -> None: + now = datetime.now(tz=timezone.utc) + ev = RunEvent( + timestamp=now, + agent_id="a", + release_id="r", + run_id="run1", + tenant_id="t", + task_id="k", + environment="local", + usage=RunEventUsage( + model=RunEventModelUsage( + provider="openai", + model="gpt-4.1-mini", + input_tokens=1, + output_tokens=2, + ) + ), + ) + payload = events_ingest_json([ev]) + assert payload is not None + assert json.loads(json.dumps(payload))["events"][0]["run_id"] == "run1" + + +def test_events_ingest_json_empty() -> None: + assert events_ingest_json([]) is None + + +def test_promote_confirm_body_shape() -> None: + b = promote_confirm_body(request_id="rid", approval_reason="ok", actor="me") + assert b["request_id"] == "rid" + assert b["actor"] == "me" + + +def test_runs_list_params_optional_keys() -> None: + p = runs_list_params( + release_id="rel", + window="1d", + environment="prod", + tenant_id="tn", + task_id="tk", + trace_id="tr", + session_id="sn", + span_id="sp", + offset=5, + limit=10, + ) + assert p["environment"] == "prod" + assert p["trace_id"] == "tr" diff --git a/uv.lock b/uv.lock index 4216caa..413a4f0 100644 --- a/uv.lock +++ b/uv.lock @@ -545,7 +545,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=13.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15,<0.16" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "temporalio", marker = "extra == 'integrations-ci'", specifier = ">=1.8.0" }, { name = "temporalio", marker = "extra == 'integrations-temporal'", specifier = ">=1.8.0" },