Skip to content

Workers AI client + partner-model pricing (typesafe/jev) - #40

Open
anassg-lago wants to merge 3 commits into
mainfrom
feat/workers-ai-client
Open

anassg-lago wants to merge 3 commits into
mainfrom
feat/workers-ai-client

Conversation

@anassg-lago

@anassg-lago anassg-lago commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Why

Cloudflare's partner models on Workers AI — typesafe/jev today — can't be wrapped: they aren't chat-shaped, and no client library can call any Workers AI model (the official cloudflare package percent-encodes the slash in every model id). Until now the only way to bill them was the Logs API backfill.

What

  • sdk.workers_ai(account_id, token, gateway_id=…, gateway_auth=…) — a one-method client, run(model, input, extra_lago=…). @cf/… models use the gateway host (cache hits skipped, cf_log_id dimension for reconciliation); partner models use the unified /ai/run path with cf-aig-gateway-id, the only route where the gateway's stored BYOK key applies.
  • New adapter extract_workers_ai_native, backed by 8 captured fixtures: chat models, a reasoning model, a real gateway cache MISS/HIT pair, and Jev both via Cloudflare and straight from TypeSafe. The requested id is the billing key (served names like …-24b-v2 miss the price catalog); the served name is kept in extras.
  • Partner models priced from Cloudflare's ai-gateway/costs table, one row per id, fetched in the background; warm_pricing(workers_ai_models=[…]) prices the very first call too. Rows keep serving past the TTL while they refresh.
  • extract_cloudflare_log exposes byok — a BYOK row carries a list-price cost Cloudflare never charged, and a backfill must not bill it.

Verified live (local Lago)

  • Jev via BYOK: 446 in / 73 out; $0.000018732 = the gateway's own log cost → new money_golden.json row.
  • 23 free-plan Workers AI models through the client, Lago totals match to the token; a cached replay bills nothing.
  • Existing wrap paths unchanged: 22 catalog models priced, 4 BYOK providers billed, partner queue untouched.
  • Python 3.10/3.11/3.12: 979 tests, ruff + mypy strict, coverage 93 %.

JS counterpart: getlago/lago-agent-sdk-js#60 (same fixtures and golden row, byte-identical).

…cost table

Cloudflare's partner models (typesafe/jev) are not chat-shaped, and no client
library can address any Workers AI model, so there is nothing for wrap() to
patch. sdk.workers_ai() is a one-method client: @cf/ ids go through the
gateway host (cache-hit skip, cf_log_id dimension), partner ids through the
unified /ai/run path with cf-aig-gateway-id, the only route that consults the
gateway's BYOK key. New adapter extract_workers_ai_native, backed by eight
captured fixtures.

Partner models are priced from GET .../ai-gateway/costs, fetched one row per
id on the queue's background tick, served stale-while-revalidate;
warm_pricing(workers_ai_models=[...]) fetches them up front so even the first
call prices. Cloudflare log entries now expose `byok` in extras, because a
BYOK row carries a list-price `cost` Cloudflare never charged.

Verified live against a local Lago: typesafe/jev through BYOK, 446 in / 73
out, and 0.000018732 USD equals the gateway's own log cost (new
money_golden.json row, identical in the JS repo).
The fake swapped in for requests.get counted every call made while it was
active, including a background price refresh from another test's still-running
queue thread, so the bounded-pagination test saw 41 pages where the loop fetched
40 (CI, ubuntu/py3.10). Only the catalog's own URL counts now; anything else
goes to the real function.

@sarkissianraffi sarkissianraffi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the harness: billing correctness, security sweep, gates, and the mirrored JS PR (getlago/lago-agent-sdk-js#60).

Blocker

  • See inline comment on docs/cloudflare.md. The new byok field is exposed and tested at the extraction layer, but the pre-existing generic Logs-API backfill example on the same page still passes entry.get("cost") straight through with no byok check, so a customer following the documented pattern will overbill every BYOK-served partner-model row. The fix is local to this same file.

What I checked

  • uv sync --all-extras --all-groups && uv run pytest tests/unit -q: 979 passed, matches the PR description.
  • uv run ruff check .: clean. uv run mypy --strict src: clean, matches the claimed "ruff + mypy strict".
  • Verified the math: 446 input tokens x $0.042/1M = $0.000018732, matches the new money_golden.json row and the PR's "verified live" claim.
  • Diffed every new fixture (workers_ai/01..08, cloudflare_gateway/03_real_workers_ai_failed.json) and the new money_golden.json row against the JS twin: byte-identical, as claimed.
  • Security sweep on git log -p main..pr40, the capture script, and all fixtures: no real account ids, tokens, or hostnames beyond the documented Cloudflare/TypeSafe endpoints. capture_workers_ai.py reads credentials from env vars only.
  • Route selection (@cf/... -> gateway/direct host, partner id -> unified /ai/run with cf-aig-gateway-id), the cache-HIT skip, and the cf-aig-skip-cache: true guard on the unified path (which has no cache header to check) are all correctly reasoned and match the module docstring's measured claims.
  • Test coverage is good: cache hit not billed, 402/403 bill nothing, success: false with HTTP 200 treated as an error, stream refused up front, instrumentation failure never breaks the caller's call.

Non-blocking

  • The _request_for / requestFor two-route split is a clean way to keep one run() method honest about two very different wire shapes. Good use of measured, dated comments instead of guessing at Cloudflare's behavior.

Merge order

  • Only other open PR in this repo is dependabot #4 (CI bump), no overlap.
  • Mirrors getlago/lago-agent-sdk-js#60 fixture-for-fixture and row-for-row. The same blocker applies there; fix both in the same pass so they stay identical.

REQUEST_CHANGES for the byok/backfill-example gap. Everything else here is solid.


Generated by Claude Code

Comment thread docs/cloudflare.md

**What the gateway buys.** With `gateway_id` set, `@cf/...` calls go through the gateway host: a cache hit (`cf-aig-cache-status: HIT`) is not billed, and every event carries a `cf_log_id` dimension that matches the entry's `id` in the Logs API. Partner models go through the unified `api.cloudflare.com/.../ai/run` path with the gateway named in a header, which is the only route where the gateway's stored partner key is consulted; that path returns no cache header, so the client asks the gateway to skip its cache for those calls rather than risk billing a cached replay. Pass `extra_headers={"cf-aig-skip-cache": "false"}` to opt back in.

**Billing.** Token events from the response's own `usage`: `prompt_tokens`/`completion_tokens` for catalog models, `input_tokens`/`output_tokens` for partner models. Catalog models price from Cloudflare's published Workers AI rates in price mode as usual. Partner models are not in that catalog; their rates come from AI Gateway's own cost table (`GET .../ai-gateway/costs`), fetched per model in the background the first time one is seen. Left alone, the very first call to a partner model in a process bills tokens and reports the miss via `on_error`, and every call after it bills dollars; name the ids up front with `sdk.warm_pricing(["workers-ai"], workers_ai_models=["typesafe/jev"])` and even the first call prices. A fetched rate keeps serving past its TTL while it refreshes, so an expiry never bills a call as tokens. Jev lists input at $0.042 per million with free output, the same rate the gateway stamps as `cost` on its log entries. Under BYOK the gateway's Logs API still reports a `cost` for the partner call at Cloudflare's list price although Cloudflare charged nothing — the entry's `byok` field (surfaced in `extras["byok"]` by `extract_cloudflare_log`) is what tells a backfill not to bill it. The id billed is the one you requested, because that is what Cloudflare's price catalog is keyed by; the name the model reports (`jev-1.13.0`, `...-24b-v2`) is kept in `extras["served_model"]`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: this paragraph states the invariant (byok tells a backfill not to bill the row) but the docs page's own generic backfill example above ("Backfill from the Logs API", the for entry in fetch_gateway_logs(): ... usd_cost=entry.get("cost") or 0" snippet) does not check it. That snippet is the money path most integrators will copy verbatim. As written, running it against a window that includes a BYOK-served typesafe/jevcall bills Cloudflare's phantom list-pricecost` even though Cloudflare charged nothing and the partner already bills the customer directly, an overbill.

Small fix: have that example skip or zero the cost when usage.extras.get("byok") is truthy (mirroring what this PR already tests in test_byok_key_source_reaches_extras_on_every_entry), or at minimum add a one-line warning right at the example, not only in this paragraph 40 lines below it.


Generated by Claude Code

The example passed the entry's `cost` straight through. On a row served with
the customer's own provider key that field is Cloudflare's list price for a
call Cloudflare never charged, so following the example overbilled every
BYOK-served partner-model call. The example now checks `extras["byok"]`,
which this PR surfaces, and bills token counts for those rows.
@anassg-lago

Copy link
Copy Markdown
Collaborator Author

Fixed in 692093c: the backfill example now checks usage.extras["byok"] and bills those rows as token counts (mode="tokens") instead of passing Cloudflare's list-price cost through. Same change on the JS twin (getlago/lago-agent-sdk-js#60), so the two pages stay mirrored.

@sarkissianraffi sarkissianraffi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after the byok/backfill fix. Only docs/cloudflare.md changed since my last review, same file I flagged.

The fix adds an extras.get("byok") check next to the loop and routes those rows through mode="tokens" instead of usd_cost=entry.get("cost"). Mirrors the JS fix exactly (extras.byok / mode: "tokens"), and the underlying extraction/token-population logic hasn't changed since I already verified it last round.

Re-ran the gates

  • uv sync --all-extras --all-groups && uv run pytest tests/unit -q: 979 passed, same as before. (The two openrouter.ai fetch failures in the output are the sandbox proxy blocking that outbound call inside an instrumentation-isolation test, not a code failure, no test reported failed.)
  • uv run ruff check .: clean. uv run mypy --strict src: clean.
  • CI on 692093c: green across py3.10/3.11/3.12 x ubuntu/macos.
  • Diffed the two new commits (python 692093c, js 4208b25): each touches only docs/cloudflare.md, no drift beyond the byok guard.

Blocker resolved. Nothing else outstanding.

Merge order (unchanged from last review)

  • Only other open PR in this repo is dependabot #4 (CI bump), no overlap.

APPROVE.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants