Skip to content

feat(billing): kbagent billing credits — PAYG balance (#594 secondary ask) - #597

Merged
padak merged 4 commits into
mainfrom
claude/issue-594-popis-2a155a
Aug 18, 2026
Merged

feat(billing): kbagent billing credits — PAYG balance (#594 secondary ask)#597
padak merged 4 commits into
mainfrom
claude/issue-594-popis-2a155a

Conversation

@padak

@padak padak commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closes the secondary ask of #594. The primary ask (purchase history + Stripe invoice IDs) stays open — see "What this deliberately does not do" below.

Why

GET /credits on the billing.{stack} host accepts a plain per-project Storage API token. It was reachable all along and simply had no command over it — rg -i "credits|payg|billing" src/ tests/ returned zero matches before this PR. Anyone running more than one PAYG project had no scriptable way to read a balance.

What lands

kbagent billing credits [--project ALIAS ...]

Multi-project parallel fan-out by default, --project (repeatable) narrows, --json emits {"credits": [...], "errors": [...]}. Mirrored on kbagent serve as GET /billing/credits.

Three details the live API forces, each of which would be a bug if guessed from the public docs:

Units. The API speaks credits; the Keboola UI displays minutes = credits × 60. Rows carry the native unit and derived minutes, and the conversion only ever runs in that direction, so a unit bug cannot invert into a wrong credit figure.

Shape. stats.workspaceJobs is an array in the live payload, not the object the public docs show. The pydantic models are therefore tolerant (extra="allow", every field defaulted) rather than strict-against-the-docs — a strict model would raise on every real response.

Availability. A project without the pay-as-you-go flag in owner.features is gated out before any billing request and reported as the new PAYG_NOT_AVAILABLE code. This is deliberately not a mapped billing 4xx: on non-PAYG stacks the service index advertises a billing. host that does not resolve, so an ungated call would surface a DNS failure instead of the actual reason. Per-project failures degrade individually; the run never aborts.

Money guardrail

The billing service also exposes POST /credits, which triggers a real automatic top-up. Nothing in kbagent wraps it: the client mixin exposes GET only, and a regression test asserts the mixin issues no POST and has no other public method. If it is ever exposed it belongs in its own PR with --dry-run/--yes and its own decision.

What this deliberately does not do

Credit purchase history and the Stripe invoice IDs Keboola already stores per project. That data lives on connection.{stack} under /pay-as-you-go/billing/*, which ignores X-StorageApi-Token entirely (a Storage-token request returns the byte-identical 302-to-login as an unauthenticated one) and rejects it as a bearer with 401. No CLI, script, or scheduled agent can reach it, so anyone funding several PAYG projects on one billing identity still cannot answer "which project does this Stripe invoice belong to" without matching on (date, amount). #594 asks maintainers whether such an endpoint already exists undocumented, whether /pay-as-you-go/billing/* would accept a kbc_at_* session bearer, or whether a new GET /credits/purchases is needed — that answer is still pending, and the command that consumes it is a follow-up PR.

Layers touched

Layer File
3 — client client/billing.py (_BillingMixin.get_credits), client/_core.py billing sub-client plumbing via the existing _derive_service_url(stack_url, "billing") — no hardcoded hostnames
— models models.py: ProjectCredits / CreditStats / ComponentJobCredits / WorkspaceJobCredits
2 — service services/billing_service.py (BillingService.get_credits)
1 — command commands/billing.py, cli.py, permissions.py (billing.creditsread)
serve server/routers/billing.py, server/dependencies.py, server/app.py
docs CLAUDE.md, commands/context.py, commands-reference.md, gotchas.md, new references/billing-workflow.md, SKILL.md

plugins/kbagent/agents/keboola-expert.md was not updated: it sits at 61,985 bytes against a 62,000-byte hard cap, leaving 15 bytes — not enough for even a one-line pointer. Flagging rather than silently skipping; the other agent surfaces (kbagent context, SKILL.md, gotchas, the new workflow doc) all carry it.

Verification

  • make check clean (lint, format, typecheck, skill, version, command-sync, changelog, error-codes, sentinel-guards, loc, tests) — 5604 passed, 12 skipped.
  • New unit coverage: 15 client tests (incl. host derivation, tolerant-parse of the verbatim live payload, and the no-POST regression guard), 14 service tests, 8 CLI tests, 4 router tests. Plus a repo E2E case in tests/test_e2e.py.
  • Live run against two real projects (e2e-snowflake 5946, e2e-bigquery 6100) via a standalone harness. Neither is PAYG-enabled, so this exercised the degradation contract end to end: well-formed envelope, exit 0, no project silently dropped, both reported as PAYG_NOT_AVAILABLE — specifically not a connection/DNS code, which is the proof the feature gate fires before the network call. --project narrowing and the exit-5 unknown-alias path also verified live.
  • Not covered live: the success-path row invariants (purchased == consumed + remaining, minutes derivation, workspace_jobs as a list) against a real PAYG payload — no PAYG project is reachable from any local config. Those are covered by unit tests using the verbatim payload captured in billing: no CLI path to PAYG credit purchases and their Stripe invoice IDs; /pay-as-you-go/billing/* is session-only #594, and the harness asserts them conditionally the moment a PAYG project is registered.

Version

Bumped to 0.84.2 with a changelog entry. Deliberately not 0.85.0: that number is reserved for the epic #390 tool-group removal, and taking it for an additive feature would make the removal announcement in gotchas.md / context.py / mcp_parity.py read as already-shipped.


Open in Devin Review

padak added 2 commits August 17, 2026 16:27
Wraps `GET /credits` on the `billing.{stack}` host, which accepts a plain
per-project Storage token -- the endpoint was reachable all along and simply
had no command over it. Multi-project parallel fan-out by default, `--project`
narrows, `--json` emits `{"credits": [...], "errors": [...]}`.

Three details the API forces:

- Units. The API speaks credits; the Keboola UI displays minutes = credits x 60.
  Rows carry the native unit AND derived minutes, and the conversion only ever
  runs in that direction, so a unit bug cannot invert into a wrong credit figure.
- Shape. `stats.workspaceJobs` is an ARRAY in the live payload, not the object
  the public docs show, so the pydantic models are tolerant (`extra="allow"`,
  every field defaulted) rather than strict-against-the-docs.
- Availability. A project without the `pay-as-you-go` flag in `owner.features`
  is gated out BEFORE any billing request and reported as the new
  `PAYG_NOT_AVAILABLE` code. This is deliberately not a mapped billing 4xx: on
  non-PAYG stacks the service index advertises a `billing.` host that does not
  resolve, so an ungated call surfaces a DNS failure instead of the real reason.

Per-project failures degrade individually and never abort the run. Mirrored on
`kbagent serve` as `GET /billing/credits`.

Read-only by design: the billing service's `POST /credits` triggers a real
automatic top-up (real money) and is wrapped by nothing in kbagent -- the client
mixin exposes GET only, and there is a regression test guarding that.

Not included, and tracked by #594's still-open primary ask: credit PURCHASE
history and the Stripe invoice IDs Keboola already stores per project. That data
lives on `connection.{stack}` under `/pay-as-you-go/billing/*`, which ignores
`X-StorageApi-Token` entirely and rejects it as a bearer with 401, so no CLI,
script, or scheduled agent can reach it today.
0.85.0 is reserved for the epic #390 `tool`-group removal; taking that
number for an additive feature would have made the removal announcement
in gotchas.md / context.py / mcp_parity.py read as already-shipped.
Renumbers the changelog key, pyproject, and every `(since vX.Y.Z)` doc
tag this PR introduced -- the #390 references to v0.85.0 stay untouched.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread src/keboola_agent_cli/services/billing_service.py
Comment thread CLAUDE.md

@padak padak left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review of #597kbagent billing credits (PAYG balance)

Generated by kbagent-pr-reviewer subagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed via make check, not duplicated here.

Summary

This PR adds kbagent billing credits, a read-only multi-project PAYG credit-balance
lookup, wired straight through all three layers plus the kbagent serve REST mirror,
with the two safety invariants called out in the review focus (no POST to the
billing host anywhere; the PAYG feature gate runs before every billing-host network
call, on both the CLI and serve paths) genuinely enforced rather than merely
documented. The implementation traces closely to issue #594's proposed scope,
including the exact verbatim live payload used as a test fixture. Verdict: APPROVE.
No blocking findings. A couple of small non-blocking notes below (one of which — a
version-number collision with the epic #390 removal — was already caught and fixed
by a follow-up commit pushed to this branch during review, so it is noted only as
confirmed-resolved, not re-raised as live).

Verdict

  • Verdict: APPROVE
  • Blocking findings: 0
  • Non-blocking findings: 2
  • Nits: 2

Blocking findings

(none)

Non-blocking findings

[NB-1] src/keboola_agent_cli/client/_core.py:466 — money guardrail is enforced by a source-scan test on client/billing.py only, not by the generic _billing_request dispatcher

_billing_request(self, method: str, path: str, **kwargs) in _core.py accepts an
arbitrary HTTP method — it is not billing-specific plumbing, it is the same shape as
every other sub-client dispatcher (_sync_actions_request, etc.). The regression
guard in tests/test_billing_client.py::TestNoTopUpHelper correctly checks that
_BillingMixin exposes only get_credits and that client/billing.py's own source
never contains the string "POST", which is a real, non-decorative guard against
today's code. But nothing stops a future PR from calling
client._billing_request("POST", "/credits") directly from a service or another
mixin — the guard would not catch that, since it only inspects billing.py. This is
a pre-existing pattern shared by every sibling sub-client (sync-actions, workspaces,
…), so it is not a defect introduced by this PR, just a gap the money-guardrail
framing invites scrutiny on. Consider a follow-up: either a repo-wide grep-based
guard for _billing_request\(.*"POST" across src/, or renaming _billing_request
to _billing_get (drop the method parameter) so the type signature itself makes a
POST impossible to construct.

[NB-2] plugins/kbagent/skills/kbagent/references/billing-workflow.md:176-180 — hardcoded observed price ("$8.40 ex. VAT per credit") risks going stale

The "Money is a separate axis again" paragraph bakes in a specific dollar rate
observed on one contract in issue #594. PAYG pricing is contract/stack-specific and
not returned by the API at all (the doc itself says "the CLI has no price field").
An AI agent reading this reference verbatim could quote that figure to a user on a
different contract as if it were universal. Consider rephrasing to make explicit
that the number is a single historical observation, not a platform constant (the
surrounding units explanation — credits vs. minutes — is the part that's actually
invariant and worth keeping verbatim).

Nits

  • [NIT-1] src/keboola_agent_cli/commands/billing.py:564-582_format_credits_table's human-mode table omits component_jobs_consumed and workspace_jobs; both are present in --json. Not required (CONTRIBUTING only asks for "informative" human output, not field parity), but a --verbose-gated breakdown row would match the richness of the JSON envelope for a human operator debugging a workspace-heavy bill.
  • [NIT-2] src/keboola_agent_cli/errors.py:708ErrorCode.PAYG_NOT_AVAILABLE: "configuration" is added to _ERROR_CODE_TO_TYPE, but the only place this code is currently emitted (billing_service.py) puts it in the per-project errors list rendered via formatter.warning(), never through formatter.error()/map_error_to_exit_code. The mapping is harmless (correct classification if the code is ever raised through the error path) but is currently unreachable dead code from this PR's own call sites — worth a one-line note in case a future reviewer wonders why it's unused.

Verification log

  • Read CONTRIBUTING.md (Checklist: Adding a New CLI Command; Plugin synchronization map; Releasing a new version) ✓
  • Read CLAUDE.md convention #17 + ## All CLI Commands (billing entry present, correctly documented) ✓
  • Read plugins/kbagent/agents/keboola-expert.md §1 rules; confirmed file is 61,985 bytes against the 62,000-byte hard cap — corroborates the PR description's claim that no room existed for even a one-line pointer; skip is legitimate, not an oversight ✓
  • gh auth status → authenticated as padak, repo/workflow scopes ✓
  • gh pr view 597 --json ... → OPEN, base main, +1645/-9, 30 files touched ✓
  • git rev-parse --abbrev-ref HEADclaude/issue-594-popis-2a155a (matches <branch>) ✓
  • gh pr diff 597 fetched twice: once mid-review at HEAD 1e6db7a (version 0.85.0), then re-fetched at HEAD 0e8b10d after a renumber commit landed mid-review (version 0.84.2, collision with epic #390's reserved tool-removal version resolved). All findings below are against the final, current HEAD 0e8b10d
  • 3-layer compliance greps (typer/formatter in services, httpx in commands, formatter/typer in clients) → all empty, no violations ✓
  • Convention greps (magic numbers, raw error_code="..." outside tests, bare except:, print(), token leakage, new bad tuple returns) → clean; the one error_code="API_ERROR" hit is in tests/test_billing_service.py, which check_error_codes.py explicitly exempts ✓
  • services/billing_service.py worker returns tuple[str, dict, ...]/tuple[str, dict] — matches the grandfathered BaseService parallel-result convention (base.py::_run_parallel), not a new bad tuple ✓
  • Read services/base.py::_run_parallel to confirm the 2-tuple-vs-3+-tuple success/error discriminator BillingService relies on ✓
  • Read client/tokens.py::has_feature / get_project_features — confirmed it calls verify_token() (Storage API, not billing host) and caches per client instance, so the PAYG gate genuinely completes before any billing-host call, in both the CLI (commands/billing.pyBillingService.get_credits) and serve (server/routers/billing.py → same BillingService instance, no separate implementation) paths ✓
  • Read tests/test_billing_client.py::TestNoTopUpHelper — confirmed the money-guardrail regression test checks (a) _BillingMixin exposes exactly one public method, (b) the module source contains no "POST"/'POST' literal, (c) an actual httpx_mock-backed call never issues a POST — a real guard, not a name-only assertion ✓
  • Cross-checked models.py ProjectCredits/CreditStats/WorkspaceJobCredits against the verbatim payload in issue #594's body (fetched via gh issue view 594) — workspaceJobs array shape, tolerant extra="allow", and the credits→minutes-only derivation direction all match the issue's acceptance criteria exactly ✓
  • Confirmed _build_credit_row in services/billing_service.py:938-970 derives consumed_minutes/remaining_minutes as credits * MINUTES_PER_CREDIT only (never the reverse) ✓
  • make check (background run) → exit 0, 5604 passed, 12 skipped, 150 deselected — matches the PR description's own reported numbers ✓
  • Live CLI smoke test: kbagent --json billing credits --help → renders correctly; kbagent --json billing credits --project pabu --project padak against two real registered (non-PAYG-verified, static-token) projects → well-formed {"credits": [], "errors": [...]} envelope, exit 0, INVALID_TOKEN surfaced per-project rather than aborting (tokens are stale/rotated on this box, so the PAYG gate itself wasn't exercised live, but the degrade-individually contract was) ✓
  • Verified billing.credits: "read" in permissions.py OPERATION_REGISTRY, check_cli_permission(ctx, "billing") in commands/billing.py, 1:1 REST mirror in server/routers/billing.py, and all six hand-maintained plugin-sync surfaces (context.py, CLAUDE.md, commands-reference.md, gotchas.md, new billing-workflow.md, SKILL.md description trigger + decision table + workflow-link table) present and consistent ✓
  • Verified version consistency across pyproject.toml, plugin.json, marketplace.json, uv.lock, changelog.py (0.84.2, correctly ordered newest-first above 0.84.1) at the final HEAD, and confirmed epic #390's pre-existing 0.85.0 references in mcp_parity.py / context.py / gotchas.md were left untouched by the renumber ✓

Open questions for the author

(none)

…ype level

NB-1: `_billing_request(method, path)` becomes `_billing_get(path)`. The
source-scan regression test only covered `client/billing.py`, so a future
caller elsewhere could still have constructed `_billing_request("POST",
"/credits")` — a real-money automatic top-up. Hardcoding the verb makes that
unconstructible, which no source-scanning test can match. Deliberately breaks
symmetry with the `_queue_request` / `_sync_actions_request` siblings; the
docstring says why. Pinned by a new test asserting the dispatcher is gone and
the replacement takes no `method` parameter.

NB-2: reframe the observed $8.40/credit rate in billing-workflow.md as a single
historical observation rather than a platform constant, and tell the reader not
to quote it — price is contract-specific, the API never returns it, and an agent
reading the doc verbatim could otherwise state it as a user's own rate.

NIT-2: note why PAYG_NOT_AVAILABLE is classified in _ERROR_CODE_TO_TYPE despite
being unreachable from today's only emitter (per-project errors render through
formatter.warning(), not formatter.error()).

NIT-1 (human table omits the per-workspace breakdown) not taken: adding a
verbose breakdown row is scope beyond #594's ask, and --json already carries
both fields.
@padak

padak commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

Review addressed in d0f8c0b — three of four taken, one declined with reasoning.

[NB-1] taken, and taken further than suggested. You offered two options; I took the stronger one. _billing_request(method, path) is now _billing_get(path) with the verb hardcoded, so _billing_request("POST", "/credits") is not merely untested — it does not exist to be written. A repo-wide grep guard would have caught the string; a signature that has no method parameter catches the whole class. This deliberately breaks symmetry with the _queue_request / _sync_actions_request siblings, and the docstring says why: those dispatchers' worst case is a wrong request, this one's is a real-money automatic top-up, so the asymmetry is the point rather than an oversight for a future reader to "fix". Pinned by a new test asserting the old dispatcher is gone and the replacement's parameter list is exactly (self, path, **kwargs).

[NB-2] taken. You were right that the hedging was too soft for an agent-facing doc — "observed in issue #594" reads as a citation, not a warning, and a model quoting $8.40 to a user on a different contract is a plausible failure. Rewritten to state outright that the credits→minutes factor is invariant while the price is not, that the figure is one historical observation rather than a platform constant, and that it must not be quoted as a user's rate or used to derive a dollar amount.

[NIT-2] taken. Added the note explaining why PAYG_NOT_AVAILABLE is classified in _ERROR_CODE_TO_TYPE even though nothing routes it through formatter.error() today: the first single-project billing command to raise it should inherit "configuration" rather than silently take the "api" default.

[NIT-1] declined. Adding a --verbose-gated breakdown row is scope past #594's ask, and component_jobs_consumed / workspace_jobs are already in --json, which is where a bill-debugging workflow lives. Worth revisiting if someone actually reaches for it at the terminal.

make check clean at the new HEAD (5605 passed, one more than before — the new signature guard). The live harness was re-run against the same two real projects after the dispatcher change: 7/7 applicable assertions pass, the PAYG-gate assertion included. Assertion 8 (success-path row invariants against a live PAYG payload) still SKIPs — no PAYG-enabled project is reachable from any local config, which the harness reports explicitly rather than passing silently.

Devin review: `consumed + remaining` equals the amount purchased only if
every credit ever added is either still available or already consumed, so
against expired, revoked, or promo credits the field would silently
under- or over-report while looking authoritative -- and the human table's
"Purchased" column header asserted it hardest.

The name was worse than a generic inaccuracy in this specific command:
credit PURCHASES are exactly what #594 is about not being able to reach.
Someone reconciling Stripe invoices could have taken this derived figure
for the purchase total the CLI explicitly cannot produce.

Renamed everywhere (row key, table column, docs, tests) with the assumption
now stated at the derivation site, in the command help, and in
billing-workflow.md. Unreleased, so no consumer contract breaks.
@padak
padak merged commit 9a5aca1 into main Aug 18, 2026
4 checks passed
@padak
padak deleted the claude/issue-594-popis-2a155a branch August 18, 2026 01:12
padak added a commit that referenced this pull request Aug 18, 2026
Main already carried an unreleased 0.84.2 (billing credits #597,
config state-get/state-set #598, kbc->kbagent CI/CD skill #402), and
v0.84.1 is the newest published release. Folding clone into that same
unreleased version ships one release instead of two, and leaves 0.85.0
free for the `tool` group removal it is already promised to (epic #390
phase 3) -- the doc references to that removal deliberately still say
0.85.0.

Version files, the changelog key (clone notes merged above the existing
0.84.2 entries) and the since-tags in CLAUDE.md, context.py, gotchas.md,
commands-reference.md, keboola-expert.md and the E2E docstring all move
to 0.84.2.
martinsifra added a commit that referenced this pull request Aug 18, 2026
…docs

Opens 0.84.3: v0.84.2 is tagged and published at main's HEAD, so there is no
in-progress key to append to, and the repo's convention is that the
substantive PR carries the bump (0.84.2 <- #594/#597, 0.84.1 <- #589,
0.84.0 <- auth login-password, ...). Neither `changelog-check` (audits that
released versions have entries) nor `version-check` (plugin.json /
marketplace.json / uv.lock vs pyproject) would have caught the omission --
the silent drift convention #17 warns about. The behaviour change is
user-visible, so it also lands in gotchas.md tagged (since v0.84.3).

Tests: the PR claimed poll counts are unchanged for every budget but nothing
pinned it. test_timeout_raises_storage_job_timeout now records sleeps and
asserts none happened -- verified that moving the deadline check after the
sleep makes it fail (assert [1.0] == []) where before it merely ran a second
slower, since the break still precedes the fetch. Adds
test_budget_below_one_interval_still_polls_once for the other half of the
claim (0.5s budget -> exactly one poll, overshoot preserved), and
test_polled_success_returns_the_polled_body: the happy path was covered only
incidentally, by a fixture that returns a terminal body and never enters the
loop.

Docstring: the "same shape as the sibling pollers" line read as a parity
claim. Narrowed -- the check-then-fetch shape matches, the behaviour does not:
this poller knows only success/error (so any other terminal status would
exhaust the budget and surface as STORAGE_JOB_TIMEOUT, where the queue poller
keys off isFinished), and its sleep is not capped to the remaining budget.
Both predate this branch.

_mk_client is now one module-level helper instead of two byte-identical
methods 62 lines apart (the only two in the suite).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant