From ceb87f70ef6b69150c6c6419082bac16e105730a Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Mon, 2 Nov 2026 13:58:29 +0100 Subject: [PATCH] feat(seats): an API seat's cycle starts on its own grant day The ledger reset every API seat's spend on one REVIEW_GATES_API_CYCLE_DAY and cleared every credit-out, an OpenRouter key's too, at that reset. Each API seat's cycle starts on its ANTHROPIC_API_KEY_GRANT_DAY now (1..31, a day past a month's end its last), else the cycle day, also 1..31. A credit-out carries across the rollover, and the run's probe tries a key held out on credit again, as `seats` does an API key: an ok lifts the mark - an API key's is new credits, its cycle restarting from the grant day, or from today when that day is over a week back; an OpenRouter key keeps its cycle's spend, and a key that answers with its cycle's budget spent stays out on that budget. A probe that finds a key out of credit records it, unless the ledger already has it out. Both are decided in one locked update on the file's entry, a mark another driver made since the probe started standing, and the ledger's stamps carry their UTC offset so marks are ordered by instant. `seats` shows the probe's reading beside the driver's verdict (the `probe` and `driver` columns replace `state` and `ledger: out`), each seat's grant day and when its next credits are due, and notes on what the probe changed in the ledger or why the driver's verdict stands; the run logs the days. --- AGENTS.md | 6 +- README.md | 2 +- implementations/Crafter.Review-Driver.cpp | 25 +- implementations/Crafter.Review-Run.cpp | 9 +- implementations/Crafter.Review-SeatPool.cpp | 322 +++++++++++++++--- implementations/Crafter.Review-Seats.cpp | 76 ++++- interfaces/Crafter.Review-SeatPool.cppm | 67 +++- interfaces/Crafter.Review-Seats.cppm | 13 +- .../net.catcrafts.CrafterReview.metainfo.xml | 5 + tests/Commands/main.cpp | 35 +- tests/SeatPool/main.cpp | 251 +++++++++++++- tests/Seats/main.cpp | 44 +++ 12 files changed, 773 insertions(+), 82 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 82934b3..a3fee23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Conventions: No file the drivers write holds these in practice, and a file that fails to parse is treated as a damaged one. - **HTTP.** `ClientHTTP1::Fetch` ignores `*_proxy` variables. The OpenRouter requests send `User-Agent: crafter-review`; `app_ready` sends `review-gates`. Reason phrases are the standard ones, not the server's. -- **Seat placement** (SeatPool, Seats, Drivers): The settings beside the seats count too. `run` (Gate 0) and `seats` each resolve the seats file from their own `--env-file` with `SeatPool::ResolveSeatsFile` (`--env-file`, `$SEATS_ENV_FILE`, `/.env` when it is a file holding a seat or a placement setting, each with a value (a blank line is neither), or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application `.env`, or one that is no file, is passed over, recorded as `passedOver` and logged by the run - the machine's own), reading each file once (`SeatsFile::Text()`: a pipe or a FIFO `--env-file` gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment, `SeatPool::WithFileTokens` - the one place, so `LoadSeats` takes the merged environment and reads no file). `SeatPool::SettingsOf` lays the file's placement settings (`Seats::SettingsFromEnvFile`, one dotenv grammar with the tokens', `DotenvLines`; `SeatPool::PlacementSettings`, the fifteen names the readers read, also deciding what makes `/.env` a seats file - a test pins that each moves a reader) under the process environment into a map handed to the placement readers alone - `ApiConfig`, `PoolConfig`, `Seats::ConfigFromEnv` - names its other `REVIEW_GATES_*` lines (the run logs them, `seats` prints them on stderr), and the process environment is not touched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one `.env` keep one board and one ledger (the Python driver's `config_env` of 2026-10-17; the first self-review's rounds 3 to 10, 2026-11-01, reworked it as an application to the process environment until round 11 found the map the readers already took; the per-seat `ANTHROPIC_API_KEY_GRANT_DAY` cycle days are still unported: one `REVIEW_GATES_API_CYCLE_DAY` for every API seat) (SeatPool). +- **Seat placement** (SeatPool, Seats, Drivers): The settings beside the seats count too. `run` (Gate 0) and `seats` each resolve the seats file from their own `--env-file` with `SeatPool::ResolveSeatsFile` (`--env-file`, `$SEATS_ENV_FILE`, `/.env` when it is a file holding a seat or a placement setting, each with a value (a blank line is neither), or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application `.env`, or one that is no file, is passed over, recorded as `passedOver` and logged by the run - the machine's own), reading each file once (`SeatsFile::Text()`: a pipe or a FIFO `--env-file` gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment, `SeatPool::WithFileTokens` - the one place, so `LoadSeats` takes the merged environment and reads no file). `SeatPool::SettingsOf` lays the file's placement settings (`Seats::SettingsFromEnvFile`, one dotenv grammar with the tokens', `DotenvLines`; `SeatPool::PlacementSettings`, the fifteen names the readers read, also deciding what makes `/.env` a seats file - a test pins that each moves a reader) under the process environment into a map handed to the placement readers alone - `ApiConfig`, `PoolConfig`, `Seats::ConfigFromEnv` - names its other `REVIEW_GATES_*` lines (the run logs them, `seats` prints them on stderr), and the process environment is not touched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one `.env` keep one board and one ledger (the Python driver's `config_env` of 2026-10-17; the first self-review's rounds 3 to 10, 2026-11-01, reworked it as an application to the process environment until round 11 found the map the readers already took) (SeatPool). - An account whose 5-hour limit Anthropic has removed reports the weekly window alone. Its burst window (`Seats::BurstWindow`) is the weekly one: it is ranked and admitted by the weekly window with the passes in flight projected on it (0.04 points per pass at xhigh, 0.125 otherwise), under the 99 % cutoff. - A pass's projected share of the windows follows the run's effort: 0.5 / 0.125 points of the 5-hour window and 0.125 / 0.04 of the weekly one at max / xhigh (`SeatPool::LensCostPct`, `LensCost7dPct`), the pooled means of 5438 passes on 141 seat-runs (2026-10-12..15) rounded up. The weekly cutoff is 99 %: the running passes are projected against it, so the last percent is spent a pass at a time. - Every OAuth account and the local login takes at most 128 passes at once, every driver's counted. @@ -119,6 +119,7 @@ Conventions: - A 'concurrents' rejection pauses the seat for 20 s or its retry-after, not until a window's reset. - Board entries and the folded `foreign` record carry `cost7d`, and so does the dump's seat summary. - The `seats` report and the log table say `no limit` in such an account's 5-hour column. +- **The ledger's cycles and credit-outs** (SeatPool, Seats, Drivers): an API seat's cycle starts on its grant day, `ANTHROPIC_API_KEY_GRANT_DAY` (`Seats::ApiGrantDays`; the seat token reader takes the line), else `REVIEW_GATES_API_CYCLE_DAY`, the day every OpenRouter key's starts on; `LoadSeats` puts an API seat's day in the seat and the ledger (`Ledger::SetDay`). A new cycle starts the spend over (`Ledger::Entry`) and carries a credit-out, with the time the key was last seen answering again. `Probe` re-probes a paid key the ledger holds out on credit (`ledgerOut` `credit`; one out on its budget it skips) - an API key with a request, an OpenRouter key through `ProbeOpenRouter` - and `seats` every API key; `Ledger::Reconcile` has the ledger follow an ok or credit-error reading in one locked update on the file's entry: an ok on a key out since before the probe started lifts the mark - an API key's is new credits (`Ledger::GrantEntry`: a cycle from the grant day when that is within the week, else from today), an OpenRouter key keeps its cycle's spend - a credit error on a key the file has as fine, with no lift seen since the probe started, is recorded, and anything else writes nothing. A mark is local time with its UTC offset (`2026-10-25T02:00:05+01:00`) and marks are ordered by instant, across the hour that repeats when DST ends and between drivers in different zones; a stamp without an offset, as older drivers wrote, is read as local time. `seats` stamps what it writes after its probe, not at the start its probes share. An ok reading over a credit error recorded since the probe started leaves the seat out, and a key that answers with its cycle's budget spent is out on that budget. `seats` shows the probe's reading beside the driver's verdict (`LedgerOut`, the one `LoadSeats` holds a seat out by), the grant day and the next credits (`NextGrant`). - **Board identity and liveness, process lifetime, stop:** see "Inside the Flatpak". - **Edge cases:** - Usage errors of `run`, `freeze`, `slots`, `config` and the other commands exit 3, the preflight's code, and so does an INVARIANT failure of `run` and `slots`. The helpers exit 64. @@ -180,6 +181,7 @@ What the binary reads at run time, and how. A variable the driver only sets or s |---|---|---|---|---| | `CLAUDE_CODE_OAUTH_TOKEN1`..`6` (+ legacy `CLAUDE_CODE_OAUTH_TOKEN`) | `:Seats` SeatTokensFromEnv, via `:SeatPool` LoadSeats (run) and `seats` | none | The fixed names 1..6 only; `TOKEN7` does nothing. Stripped; an empty value is no seat. The bare name is seat 1 when `TOKEN1` is empty. The env file's lines override the process environment. Stripped from every child's environment. | OAuth seats `1`..`6`; a token reaches claude only as its seat's `CLAUDE_CONFIG_DIR/.credentials.json` | | `ANTHROPIC_API_KEY1`..`6` | `:Seats` ApiKeysFromEnv (run, seats) | none | The fixed names 1..6, stripped; empty is no seat. The bare `ANTHROPIC_API_KEY` is never a seat (the env-file parser wants the digit too) and is stripped from every child. | API-credit seats `api1`..`api6`, each key in its own process's `ANTHROPIC_API_KEY` only | +| `ANTHROPIC_API_KEY1_GRANT_DAY`..`6` | `:Seats` ApiGrantDays, via LoadSeats (run) and `seats` | `REVIEW_GATES_API_CYCLE_DAY` | Stripped decimal digits of any script, 1..31; anything else is ignored. The env file's lines override the process environment. | The day of the month seat n's ledger cycle starts on | | `OPENROUTER_KEY`, `OPENROUTER_KEY1`..`6` | `:Seats` OpenRouterKeysFromEnv, via LoadSeats (run; `seats` ignores them) | none | As the OAuth tokens; the bare name is `or1` when `KEY1` is empty. Never in `auto`: `--seats openrouter` or `orN` ids; mixed with other seats it is a preflight error (exit 3). | OpenRouter seats `or1`..`or6` | | `SEATS_ENV_FILE` | `:SeatPool` ResolveSeatsFile (run's Gate 0, `seats`) | `/.env` if it is a file holding a seat or a placement setting, each with a value (or no UTF-8 text: its error is the command's), else the machine's `$XDG_CONFIG_HOME/crafter-review/env` (`~/.config/crafter-review/env`) | `--env-file` wins (in run and `seats` even `--env-file ""`, meaning no file); empty counts as unset. Seat lines are taken (`[export ]NAME=value`, one pair of matching quotes stripped), overriding the process environment; the file's placement settings (`SeatPool::PlacementSettings`) go, under the process environment, into the map the placement readers take (`SeatPool::SettingsOf`, from the one read the tokens come from too), so every placement knob below may come from the file, the process environment winning; no other reader sees a line of it, and its other `REVIEW_GATES_*` lines are named (the run's log, `seats` on stderr). A file that cannot be read, or is not UTF-8: run stops at preflight (exit 3), `seats` exits 1 with the read error. | The dotenv file the seat secrets come from | | `XDG_CONFIG_HOME` | `:SeatPool` GlobalEnvFile | `~/.config` | The directory whose `crafter-review/env` is the machine's own seats file, the last place a run or `seats` looks (2026-11-01). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point it (and `HOST_XDG_CONFIG_HOME`) at a path nothing creates, so a global file on the machine stays out of them. | @@ -195,7 +197,7 @@ What the binary reads at run time, and how. A variable the driver only sets or s | `REVIEW_GATES_API_BUDGET_USD` | `:SeatPool` ApiConfig (run, seats) | `200.0` | MAX_UTIL's syntax, kept if >= 0 (`inf` means no cap); empty, garbage, negative, `nan` give the default. `--api-budget` overrides it in run. | USD one API key may spend per cycle; at it the key is out | | `REVIEW_GATES_API_LENS_COST_USD` | `:SeatPool` ApiConfig | `1.0` | As API_BUDGET_USD | USD reserved per running process: a pass fits while spent + (running+1) × this + other drivers' cost ≤ budget | | `REVIEW_GATES_API_CONCURRENCY` | `:SeatPool` ApiConfig | `8` | As API_BUDGET_USD, truncated toward zero; `0` keeps every pass off the API seats; `inf` exits 1 with an OverflowError. | Processes at once on one API seat, every driver's counted | -| `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..28. | The day of the month the ledger's spend resets | +| `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..31; a day past a month's end is its last day. | The grant day of an API seat without its own `_GRANT_DAY`, and the day the driver's spend on every OpenRouter key starts over | | `REVIEW_GATES_OPENROUTER_BUDGET_USD` | `:SeatPool` ApiConfig | `100.0` | As API_BUDGET_USD | USD per cycle per OpenRouter key | | `REVIEW_GATES_OPENROUTER_LENS_COST_USD` | `:SeatPool` ApiConfig | `0.1` | As API_BUDGET_USD | Reserve per process on an OpenRouter seat | | `REVIEW_GATES_OPENROUTER_CONCURRENCY` | `:SeatPool` ApiConfig | `16` | As API_CONCURRENCY | Processes at once on one OpenRouter seat | diff --git a/README.md b/README.md index 4c4b8e8..19a12bb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The machine provides the rest: | | | |---|---| | **The Claude Code CLI** | Proprietary, not bundled. Found through `CLAUDE_BIN` or `REVIEW_GATES_CLAUDE_BIN`, the PATH, an editor extension's bundled binary, `~/.claude/local` or `~/.local/bin`. | -| **Seats** | A dotenv file with `CLAUDE_CODE_OAUTH_TOKEN1..6` or `ANTHROPIC_API_KEY1..6`: `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` when it holds a seat or a placement setting, else `~/.config/crafter-review/env`. Its seats go over the environment's; with none, your own login. The file's `REVIEW_GATES_*` placement settings count too. `crafter-review seats` probes them; every driver on the machine shares one seat board. | +| **Seats** | A dotenv file with `CLAUDE_CODE_OAUTH_TOKEN1..6` or `ANTHROPIC_API_KEY1..6`: `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` when it holds a seat or a placement setting, else `~/.config/crafter-review/env`. Its seats go over the environment's; with none, your own login. The file's `REVIEW_GATES_*` placement settings count too; `ANTHROPIC_API_KEY_GRANT_DAY` sets the day an API seat's cycle starts. `crafter-review seats` probes them; every driver on the machine shares one seat board. | | **Chromium** | For a lens with the browser: `crafter-review browser install`, once. | | **A local Supabase stack** | For a lens with the database (`supabase start`, or the repository's own bring-up). The `sql` helper speaks the Postgres protocol itself; every call is one transaction, rolled back. | | **gpg** | Only for signed commits: `freeze` reads the signatures through gnupg's keyboxd socket. | diff --git a/implementations/Crafter.Review-Driver.cpp b/implementations/Crafter.Review-Driver.cpp index cddb026..d9de552 100644 --- a/implementations/Crafter.Review-Driver.cpp +++ b/implementations/Crafter.Review-Driver.cpp @@ -534,7 +534,8 @@ inventory unit, against a frozen commit range.)"; constexpr std::string_view Prog = "crafter-review seats"; const Parsed parsed = ParseOptions(args, SeatsOptions); if (parsed.help) { - std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 from the environment and/or the dotenv file. Never prints a token.\n", + std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 and ANTHROPIC_API_KEY1..6 from the environment and/or the dotenv file. Never prints a token. An API key's " + "probe is recorded in the seat ledger: new credits, or the credit error.\n", Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions)); return 0; } @@ -603,9 +604,23 @@ inventory unit, against a frozen commit range.)"; Seats::ProbeOptions api = haiku; api.api = true; apiSeats = ProbeAll(todo, api); - // The driver's ledger: what it spent on each key this cycle. - SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig); - for (Json& s : apiSeats) s["ledger"] = ledger.Entry(std::format("api{}", s.at("seat").get())); + // The driver's ledger: what it spent on each key this cycle, + // from each seat's grant day. It follows the probe: a key that + // answers again after the credit error is new credits, the + // credit error on a key it has as fine is recorded. + std::map days; + for (const auto& [n, day] : Seats::ApiGrantDays(env)) days[std::format("api{}", n)] = day; + SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig, days); + for (Json& s : apiSeats) { + const std::string id = std::format("api{}", s.at("seat").get()); + s["grantDay"] = ledger.Day(id); + // `now`, taken before the probes started, is their start; + // what Reconcile writes is stamped when it is written. + s["event"] = ledger.Reconcile(id, s.at("state"), now); + s["ledger"] = ledger.Entry(id, now); + s["ledgerOut"] = SeatPool::LedgerOut(s.at("ledger"), apiConfig.at("budgetUsd").get()); + s["nextCredits"] = SeatPool::NextGrant(now, ledger.Day(id), Dyn::Get(s.at("ledger"), "outSince")); + } } // The board: the drivers running now and what they hold on each // account - read, never written. @@ -631,7 +646,7 @@ inventory unit, against a frozen commit range.)"; for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get())); std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin); if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys)); - if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, board, apiKeys)); + if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, now, board, apiKeys)); std::println("\n{}", Seats::RenderDrivers(board, now, zone)); // The pids above are no use from inside the sandbox: the way to // stop a run instead. diff --git a/implementations/Crafter.Review-Run.cpp b/implementations/Crafter.Review-Run.cpp index 410ce5d..62b28c5 100644 --- a/implementations/Crafter.Review-Run.cpp +++ b/implementations/Crafter.Review-Run.cpp @@ -767,7 +767,7 @@ namespace Crafter::Review::Run { std::ranges::sort(sorted); Die(std::format("OpenRouter seats need OpenRouter model ids (vendor/model): --models opus=...,fable=... (now {})", Dyn::Join(sorted, ", "))); } - if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log); + if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log, {}, ledger.get()); if (board) { // Our readings onto the board (a probed-ok seat clears a verdict // another driver left), theirs into our seats. @@ -793,7 +793,12 @@ namespace Crafter::Review::Run { for (const Json& s : seats) names.push_back(std::format("{} ({})", Dyn::Str(s.at("id")), Dyn::Str(s.at("kind")))); std::string line = std::format("seats: {} from {}", Dyn::Join(names, ", "), envFile && !envFile->empty() ? *envFile : std::string("this machine")); if (std::ranges::any_of(seats, [](const Json& s) { return s.at("kind") == "api"; })) { - line += std::format("; {}, budget ${:g} per api seat per cycle (ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), ledger->Path().string()); + std::vector days; + for (const Json& s : seats) { + if (s.at("kind") == "api") days.push_back(std::format("{} {}", Dyn::Str(s.at("id")), Dyn::Str(Dyn::Get(s, "grantDay")))); + } + line += std::format("; {}, budget ${:g} per api seat per cycle from its grant day ({}; ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), Dyn::Join(days, ", "), + ledger->Path().string()); } if (openrouter) { line += std::format("; budget ${:g} per OpenRouter seat per cycle, {} concurrent (ledger {})", Number(apiCfg.at("orBudgetUsd")), Dyn::Str(apiCfg.at("orConcurrency")), ledger->Path().string()); diff --git a/implementations/Crafter.Review-SeatPool.cpp b/implementations/Crafter.Review-SeatPool.cpp index 2f908f3..8c54da6 100644 --- a/implementations/Crafter.Review-SeatPool.cpp +++ b/implementations/Crafter.Review-SeatPool.cpp @@ -206,7 +206,8 @@ namespace Crafter::Review::SeatPool { {"budgetUsd", num("REVIEW_GATES_API_BUDGET_USD", 200.0)}, {"lensCostUsd", num("REVIEW_GATES_API_LENS_COST_USD", 1.0)}, {"concurrency", Int(num("REVIEW_GATES_API_CONCURRENCY", 8))}, - {"cycleDay", std::max(1, std::min(28, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))}, + // A day past a month's end is its last day (CycleStart). + {"cycleDay", std::max(1, std::min(31, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))}, {"order", EnvOf(env, "REVIEW_GATES_SEAT_ORDER") == "oauth-first" ? "oauth-first" : "api-first"}, // OpenRouter seats: a prepaid key, cheap models - a smaller reserve // per running process. @@ -233,47 +234,153 @@ namespace Crafter::Review::SeatPool { return root / "seat-ledger.json"; } + namespace { + std::chrono::year_month_day LocalDate(double now) { + using namespace std::chrono; + const std::string today = Platform::LocalTime("%Y-%m-%d", now); + return {year(std::stoi(today.substr(0, 4))), month(static_cast(std::stoi(today.substr(5, 2)))), day(static_cast(std::stoi(today.substr(8, 2))))}; + } + + std::chrono::year_month_day ParseIso(std::string_view iso) { + using namespace std::chrono; + const std::string text(iso); + return {year(std::stoi(text.substr(0, 4))), month(static_cast(std::stoi(text.substr(5, 2)))), day(static_cast(std::stoi(text.substr(8, 2))))}; + } + + std::string Iso(const std::chrono::year_month_day& d) { + return std::format("{:04d}-{:02d}-{:02d}", static_cast(d.year()), static_cast(d.month()), static_cast(d.day())); + } + + // A ledger stamp: local time with its UTC offset, + // 2026-10-25T02:00:05+01:00, so any driver orders it by instant across + // the hour that repeats when DST ends, or from another zone. + std::string Stamp(double at) { + std::string text = Platform::LocalTime("%Y-%m-%dT%H:%M:%S%z", at); + if (text.size() == 24) text.insert(22, ":"); + return text; + } + + // A stamp's instant, epoch seconds: by its offset, else (a stamp + // written before 1.1.0) as local time; none when it cannot be read. + std::optional Instant(std::string_view stamp) { + auto number = [&](std::size_t from, std::size_t width) -> std::optional { + if (from + width > stamp.size()) return std::nullopt; + std::int32_t value = 0; + for (std::size_t i = from; i < from + width; ++i) { + if (stamp[i] < '0' || stamp[i] > '9') return std::nullopt; + value = value * 10 + (stamp[i] - '0'); + } + return value; + }; + if (stamp.size() < 19 || stamp[4] != '-' || stamp[7] != '-' || stamp[10] != 'T' || stamp[13] != ':' || stamp[16] != ':') return std::nullopt; + const std::optional y = number(0, 4), mo = number(5, 2), d = number(8, 2), h = number(11, 2), mi = number(14, 2), s = number(17, 2); + if (!y || !mo || !d || !h || !mi || !s) return std::nullopt; + const double clock = *h * 3600.0 + *mi * 60.0 + *s; + if (stamp.size() == 25 && (stamp[19] == '+' || stamp[19] == '-') && stamp[22] == ':') { + const std::optional oh = number(20, 2), om = number(23, 2); + const std::chrono::year_month_day date {std::chrono::year(*y), std::chrono::month(static_cast(*mo)), std::chrono::day(static_cast(*d))}; + if (!oh || !om || !date.ok()) return std::nullopt; + const double offset = (stamp[19] == '-' ? -1.0 : 1.0) * (*oh * 3600.0 + *om * 60.0); + return static_cast(std::chrono::sys_days(date).time_since_epoch().count()) * 86400.0 + clock - offset; + } + if (stamp.size() != 19) return std::nullopt; + std::tm local {}; + local.tm_year = *y - 1900; + local.tm_mon = *mo - 1; + local.tm_mday = *d; + local.tm_hour = *h; + local.tm_min = *mi; + local.tm_sec = *s; + local.tm_isdst = -1; + const std::time_t seconds = std::mktime(&local); + if (seconds == static_cast(-1)) return std::nullopt; + return static_cast(seconds); + } + + // That day of the month, a day past its end its last day. + std::chrono::year_month_day OnDay(const std::chrono::year_month& ym, std::int32_t day) { + using namespace std::chrono; + const auto last = static_cast(static_cast(year_month_day_last(ym.year(), month_day_last(ym.month())).day())); + return {ym.year(), ym.month(), std::chrono::day(static_cast(std::clamp(day, 1, last)))}; + } + } + std::string CycleStart(double now, std::int32_t day) { using namespace std::chrono; - const std::string today = Platform::LocalTime("%Y-%m-%d", now); - const year_month_day date(year(std::stoi(today.substr(0, 4))), month(static_cast(std::stoi(today.substr(5, 2)))), - std::chrono::day(static_cast(std::stoi(today.substr(8, 2))))); - auto iso = [](const year_month_day& d) { - return std::format("{:04d}-{:02d}-{:02d}", static_cast(d.year()), static_cast(d.month()), static_cast(d.day())); - }; - if (static_cast(static_cast(date.day())) >= day) { - return iso(year_month_day(date.year(), date.month(), std::chrono::day(static_cast(day)))); - } - const year_month_day previous(sys_days(year_month_day(date.year(), date.month(), std::chrono::day(1))) - days(1)); - const auto last = static_cast(static_cast(previous.day())); - return iso(year_month_day(previous.year(), previous.month(), std::chrono::day(static_cast(std::min(day, last))))); + const year_month_day date = LocalDate(now); + const year_month ym(date.year(), date.month()); + const year_month_day thisMonth = OnDay(ym, day); + return Iso(date >= thisMonth ? thisMonth : OnDay(ym - months(1), day)); + } + + std::string NextGrant(double now, std::int32_t day, const Json& outSince) { + using namespace std::chrono; + const std::string cycle = CycleStart(now, day); + if (Dyn::Truthy(outSince) && Dyn::Slice(Dyn::Str(outSince), 0, 10) < cycle) return cycle; + const year_month_day start = ParseIso(cycle); + return Iso(OnDay(year_month(start.year(), start.month()) + months(1), day)); + } + + std::pair LedgerVerdict(const Json& entry, double budget) { + const Json& outSince = Dyn::Get(entry, "outSince"); + if (Dyn::Truthy(outSince)) return {true, std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(entry, "outReason"), "out"), Dyn::Slice(Dyn::Str(outSince), 0, 16))}; + const double spent = Num(Dyn::Get(entry, "spentUsd")); + if (spent >= budget) return {true, std::format("ledger: ${:.2f} spent this cycle, budget ${}", spent, G(budget))}; + return {false, std::string()}; + } + + Json LedgerOut(const Json& entry, double budget) { + if (!LedgerVerdict(entry, budget).first) return nullptr; + return Dyn::Truthy(Dyn::Get(entry, "outSince")) ? "credit" : "budget"; } // ---- the ledger ------------------------------------------------------------------- - Ledger::Ledger(fs::path path, Json apiConfig, Json data) : path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()) {} + Ledger::Ledger(fs::path path, Json apiConfig, Json data, std::map days) + : path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()), days(std::move(days)) {} - Ledger Ledger::Load(const fs::path& path, const Json& apiConfig) { + Ledger Ledger::Load(const fs::path& path, const Json& apiConfig, std::map days) { Json data; try { data = Dyn::LoadJsonFile(path); } catch (const std::exception&) { data = Json::object(); } - return Ledger(path, apiConfig, data.is_object() ? data : Json::object()); + return Ledger(path, apiConfig, data.is_object() ? data : Json::object(), std::move(days)); + } + + std::int32_t Ledger::Day(const std::string& seatId) const { + const auto found = days.find(seatId); + if (found != days.end() && found->second > 0) return found->second; + return static_cast(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))); + } + + void Ledger::SetDay(const std::string& seatId, std::int32_t day) { + days[seatId] = day; } Json& Ledger::Entry(const std::string& seatId, std::optional now) { - const std::string cycle = CycleStart(NowOr(now), static_cast(Int(AsDouble(Dyn::At(apiConfig, "cycleDay"))))); + const std::string cycle = CycleStart(NowOr(now), Day(seatId)); const Json& e = Dyn::Get(data, seatId); - // Only a newer cycle starts over: an entry of a later one (another - // driver's cycle day) is not wiped. - if (!Dyn::Truthy(e) || cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) { + if (!Dyn::Truthy(e)) { data[seatId] = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}}; + } else if (cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) { + // Only a newer cycle starts over: an entry of a later one (another + // driver's cycle day, or a grant seen early) is not wiped. A + // credit-out carries: the cycle's day is a configured date, the + // credit error was observed (2026-10-17: the calendar cycle held + // api3 out for the first week of its new credits; an OpenRouter + // key's turn puts nothing on it), and only a probe that sees the + // key answer again lifts it (Reconcile). When the key was last seen + // answering again carries too: Reconcile records no credit error + // read before it. + Json next = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", Dyn::Get(e, "outSince")}, {"outReason", Dyn::Get(e, "outReason")}}; + if (Dyn::Truthy(Dyn::Get(e, "grantSeen"))) next["grantSeen"] = Dyn::Get(e, "grantSeen"); + data[seatId] = std::move(next); } return data[seatId]; } - Json Ledger::Update(const std::string& seatId, const std::function& change, std::optional now) { + Json Ledger::Update(const std::string& seatId, const std::function& change, std::optional now) { // The file is the record: re-read under its lock, this process only // ever adds to it (2026-10-15: each driver saved the copy it had loaded // and the last writer won - api4 reached the credit error with $157.54 @@ -285,10 +392,10 @@ namespace Crafter::Review::SeatPool { for (auto it = file.begin(); it != file.end(); ++it) merged[it.key()] = it.value(); data = std::move(merged); Json& e = Entry(seatId, now); - change(e); + const bool write = change(e); entry = e; - file = data; - return true; + if (write) file = data; + return write; }, data, 0600, {}); return entry; } @@ -297,16 +404,70 @@ namespace Crafter::Review::SeatPool { return Update(seatId, [&](Json& e) { e["spentUsd"] = Dyn::Round(AsDouble(Dyn::At(e, "spentUsd")) + usd, 4); e["procs"] = Dyn::Add(Dyn::At(e, "procs"), Json(1)); + return true; }, now); } Json Ledger::MarkOut(const std::string& seatId, std::string_view reason, std::optional now) { return Update(seatId, [&](Json& e) { - e["outSince"] = Platform::LocalTime("%Y-%m-%dT%H:%M:%S", NowOr(now)); + e["outSince"] = Stamp(NowOr(now)); e["outReason"] = Dyn::Slice(reason, 0, 160); + return true; }, now); } + Json Ledger::GrantEntry(const std::string& seatId, double at) const { + using namespace std::chrono; + const std::string cycle = CycleStart(at, Day(seatId)); + const std::string weekAgo = Iso(year_month_day(sys_days(LocalDate(at)) - std::chrono::days(7))); + const std::string start = cycle >= weekAgo ? cycle : Iso(LocalDate(at)); + return {{"cycle", start}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}, {"grantSeen", Stamp(at)}}; + } + + std::string Ledger::Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional now) { + if (state != "ok" && state != "out") return {}; + const double at = NowOr(now); + // A mark made before the probe started, by instant to the second the + // stamps hold; one made since may postdate what the probe read, and + // one that cannot be read counts as made since. + const double started = std::floor(probedAt); + auto before = [&](const Json& stamp) { + const std::optional instant = Instant(Dyn::Str(stamp)); + return instant && *instant < started; + }; + const Json fresh = GrantEntry(seatId, at); + std::string event; + // Decided on the file's entry under the lock, not on this process's + // copy: another driver may have recorded the grant and spent on it, + // or the credit error, since this copy was read. + // An OpenRouter key (or) is prepaid: its lift is no grant, no new + // cycle - the mark goes, the cycle's spend stays. + const bool openRouter = seatId.starts_with("or"); + Update(seatId, [&](Json& e) { + const Json& outSince = Dyn::Get(e, "outSince"); + if (state == "ok") { + if (!Dyn::Truthy(outSince) || !before(outSince)) return false; + if (openRouter) { + e["outSince"] = nullptr; + e["outReason"] = nullptr; + e["grantSeen"] = Stamp(at); + event = "credit-back"; + } else { + e = fresh; + event = "new-grant"; + } + return true; + } + const Json& seen = Dyn::Get(e, "grantSeen"); + if (Dyn::Truthy(outSince) || (Dyn::Truthy(seen) && !before(seen))) return false; + e["outSince"] = Stamp(at); + e["outReason"] = openRouter ? "key limit reached" : "credit balance too low"; + event = "credit-gone"; + return true; + }, at); + return event; + } + void Ledger::Save() { try { Platform::WriteAtomic(path, DumpFile(data), 0600); @@ -319,6 +480,10 @@ namespace Crafter::Review::SeatPool { return data; } + const Json& Ledger::ApiConfig() const { + return apiConfig; + } + const fs::path& Ledger::Path() const { return path; } @@ -436,7 +601,7 @@ namespace Crafter::Review::SeatPool { return env; } - std::vector LoadSeats(const std::map& env, std::string_view specText, Ledger* ledger, const Json& apiConfig) { + std::vector LoadSeats(const std::map& env, std::string_view specText, Ledger* ledger, const Json& apiConfig, std::optional now) { const std::map& merged = env; std::vector> pool; for (const auto& [n, key] : Seats::ApiKeysFromEnv(merged)) pool.emplace_back(std::format("api{}", n), NewSeat(std::format("api{}", n), "api", key)); @@ -482,18 +647,31 @@ namespace Crafter::Review::SeatPool { if (std::ranges::any_of(seats, IsOpenRouter) && !std::ranges::all_of(seats, IsOpenRouter)) { throw std::invalid_argument("--seats: an OpenRouter seat cannot share a pool with Anthropic seats or the local login - a model name means a different model per endpoint"); } + // An API seat's cycle starts on its grant day (2026-10-17): the day + // its organization receives the plan's credits, the Console's + // Promotional-credits expiry minus three days. + const std::map grantDays = Seats::ApiGrantDays(env); + for (Json& s : seats) { + if (Kind(s) != "api") continue; + const auto found = grantDays.find(static_cast(Dyn::ParseInt(Dyn::Slice(Id(s), 3)).value_or(0))); + const std::int32_t day = found != grantDays.end() ? found->second : (Dyn::Truthy(apiConfig) ? static_cast(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))) : 1); + s["grantDay"] = day; + if (ledger) ledger->SetDay(Id(s), day); + } if (ledger) { - const double now = Platform::Now(); + const double at = NowOr(now); for (Json& s : seats) { if (!IsPaid(s)) continue; - const Json& e = ledger->Entry(Id(s), now); + const Json& e = ledger->Entry(Id(s), at); s["spentUsd"] = AsDouble(Dyn::At(e, "spentUsd")); - if (Dyn::Truthy(Dyn::Get(e, "outSince"))) { + const double budget = Dyn::Truthy(apiConfig) ? Budget(s, apiConfig) : std::numeric_limits::infinity(); + const auto [out, why] = LedgerVerdict(e, budget); + if (out) { s["state"] = "out"; - s["error"] = std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(e, "outReason"), "out"), Dyn::Slice(Dyn::Str(e.at("outSince")), 0, 16)); - } else if (Dyn::Truthy(apiConfig) && AsDouble(s.at("spentUsd")) >= Budget(s, apiConfig)) { - s["state"] = "out"; - s["error"] = std::format("ledger: ${:.2f} spent this cycle, budget ${}", AsDouble(s.at("spentUsd")), G(Budget(s, apiConfig))); + s["error"] = why; + // Why the ledger holds the seat out: a credit-out is + // probed again, a spent budget is not. + s["ledgerOut"] = LedgerOut(e, budget); } } } @@ -541,7 +719,62 @@ namespace Crafter::Review::SeatPool { return std::format("{}: {}{}", Dyn::Str(s.at("state")), Dyn::Join(parts, ", "), Dyn::Truthy(binding) ? std::format(" (binding {})", Dyn::Str(binding)) : std::string()); } - void Probe(std::vector& seats, const std::string& claudeBin, double timeout, const std::vector& models, const Log& log, const ProbeFunction& probe) { + namespace { + // A paid key after its probe (started at probedAt): the seat takes + // the spend the file holds, every driver's; a lift puts a held-out + // seat back (an API key's with its cycle restarted), a credit error + // recorded since the probe started stands over its answer, the credit + // error takes it out (an API key's with the ledger's record and the + // next credits' date in the reason), an unreadable probe leaves a + // held-out seat held out. + void AfterPaidProbe(Json& s, const Json& held, Ledger* ledger, double probedAt, double now, const Log& log) { + const std::string event = ledger ? ledger->Reconcile(Id(s), s.at("state"), probedAt, now) : std::string(); + const Json entry = ledger ? ledger->Entry(Id(s), now) : Json::object(); + if (ledger) s["spentUsd"] = Num(Dyn::Get(entry, "spentUsd")); + if (event == "new-grant") { + s["ledgerOut"] = nullptr; + s["error"] = nullptr; + log(std::format("seat {}: the key answers again - new credits; the ledger cycle restarts (from {})", Id(s), Dyn::Str(entry.at("cycle")))); + } else if (event == "credit-back") { + s["ledgerOut"] = nullptr; + s["error"] = nullptr; + log(std::format("seat {}: the key's limit has room again - back, its cycle's spend kept", Id(s))); + } else if (s.at("state") == "ok" && Dyn::Truthy(Dyn::Get(entry, "outSince"))) { + s["state"] = "out"; + s["ledgerOut"] = "credit"; + s["error"] = LedgerVerdict(entry, std::numeric_limits::infinity()).second; + } else if (s.at("state") == "ok") { + s["ledgerOut"] = nullptr; + } else if (s.at("state") == "out" && IsOpenRouter(s)) { + // The probe's error says why. + s["ledgerOut"] = "credit"; + } else if (s.at("state") == "out") { + const Json e = ledger ? ledger->Entry(Id(s), now) : Json::object(); + const Json& outSince = Dyn::Get(e, "outSince"); + const Json& day = Dyn::Get(s, "grantDay"); + s["ledgerOut"] = "credit"; + s["error"] = std::format("credit balance too low{}; next credits due {}", Dyn::Truthy(outSince) ? std::format(" since {}", Dyn::Slice(Dyn::Str(outSince), 0, 16)) : std::string(), + NextGrant(now, Dyn::Truthy(day) ? static_cast(Int(AsDouble(day))) : 1, outSince)); + } else if (s.at("state") == "unknown" && Dyn::Truthy(held)) { + s["state"] = "out"; + s["error"] = std::format("{} (probe unreadable: {})", Dyn::Str(held), Dyn::Str(Dyn::Get(s, "error"))); + } + // A key that answers with its cycle's budget spent is out on that + // budget, as LoadSeats holds such a seat out: an OpenRouter key's + // lift keeps its spend, and another driver may have spent on a + // grant it saw. Left ok, the seat would be neither placed nor out. + if (ledger && s.at("state") == "ok") { + if (const auto [out, why] = LedgerVerdict(entry, Budget(s, ledger->ApiConfig())); out) { + s["state"] = "out"; + s["ledgerOut"] = "budget"; + s["error"] = why; + } + } + } + } + + void Probe(std::vector& seats, const std::string& claudeBin, double timeout, const std::vector& models, const Log& log, const ProbeFunction& probe, Ledger* ledger, + std::optional now, const OpenRouterProbeFunction& probeKey) { const ProbeFunction run = probe ? probe : ProbeFunction([](std::int32_t seat, const std::string& token, const std::string& model, double t, const std::string& bin, bool local, bool api) { return Seats::ProbeSeat(seat, token, {.model = model, .timeoutS = t, .claudeBin = bin, .local = local, .api = api}); }); @@ -555,7 +788,7 @@ namespace Crafter::Review::SeatPool { break; } } - if (std::ranges::any_of(seats, IsOpenRouter)) { + if (!models.empty() && std::ranges::any_of(seats, IsOpenRouter)) { const Json prices = FetchOpenRouterPrices(models, log); for (auto it = prices.begin(); it != prices.end(); ++it) { const Json& p = it.value(); @@ -569,22 +802,33 @@ namespace Crafter::Review::SeatPool { } const std::string lensModel = fable.value_or(std::string(Seats::ProbeModel)); for (Json& s : seats) { - if (s.at("state") == "out") continue; const std::string kind = Kind(s); + if (s.at("state") == "out" && !(IsPaid(s) && Dyn::Get(s, "ledgerOut") == "credit")) continue; const std::string id = Id(s); const std::string secret = Dyn::StrOr(Dyn::Get(s, "secret"), ""); if (kind == "openrouter") { - const Json r = ProbeOpenRouter(secret, timeout); + // The ledger's verdict before the probe. + const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json(); + const double probedAt = NowOr(now); + const Json r = probeKey ? probeKey(secret, timeout) : ProbeOpenRouter(secret, timeout); ApplyProbe(s, r); s["remainingUsd"] = Dyn::Get(r, "remaining"); + AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log); log(std::format("seat {} ({}): {}", id, kind, Describe(s))); continue; } const std::int32_t n = kind == "api" ? static_cast(Dyn::ParseInt(Dyn::Slice(id, 3)).value_or(0)) : (kind == "local" ? 0 : static_cast(Dyn::ParseInt(id).value_or(0))); Json r; if (kind == "api") { - r = run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true); - } else if (kind == "local") { + // The ledger's verdict before the probe. + const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json(); + const double probedAt = NowOr(now); + ApplyProbe(s, run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true)); + AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log); + log(std::format("seat {} ({}): {}", id, kind, Describe(s))); + continue; + } + if (kind == "local") { r = run(n, "", lensModel, timeout, claudeBin, true, false); } else { r = run(n, secret, lensModel, timeout, claudeBin, false, false); diff --git a/implementations/Crafter.Review-Seats.cpp b/implementations/Crafter.Review-Seats.cpp index 80a97cb..da5c470 100644 --- a/implementations/Crafter.Review-Seats.cpp +++ b/implementations/Crafter.Review-Seats.cpp @@ -298,6 +298,20 @@ namespace Crafter::Review::Seats { return out; } + std::map ApiGrantDays(const std::map& env) { + std::map out; + for (std::int32_t i = 1; i <= MaxSeats; ++i) { + // Decimal digits of any script; past 31 the value stays out of + // range. + const std::u32string value = Dyn::Decode(Dyn::Strip(EnvOf(env, std::format("ANTHROPIC_API_KEY{}_GRANT_DAY", i)))); + if (value.empty() || !std::ranges::all_of(value, [](char32_t c) { return Dyn::DecimalValue(c) >= 0; })) continue; + std::int32_t day = 0; + for (const char32_t c : value) day = std::min(day * 10 + Dyn::DecimalValue(c), 32); + if (day >= 1 && day <= 31) out[i] = day; + } + return out; + } + std::vector> OpenRouterKeysFromEnv(const std::map& env) { std::vector> out; for (std::int32_t i = 1; i <= MaxSeats; ++i) { @@ -341,7 +355,9 @@ namespace Crafter::Review::Seats { for (const auto& [name, value] : DotenvLines(text)) { for (const std::string_view stem : {std::string_view("CLAUDE_CODE_OAUTH_TOKEN"), std::string_view("ANTHROPIC_API_KEY"), std::string_view("OPENROUTER_KEY")}) { if (!name.starts_with(stem)) continue; - const std::string_view rest = std::string_view(name).substr(stem.size()); + std::string_view rest = std::string_view(name).substr(stem.size()); + // ANTHROPIC_API_KEY_GRANT_DAY: the seat's grant day. + if (stem == "ANTHROPIC_API_KEY" && rest.size() == 11 && rest.ends_with("_GRANT_DAY")) rest = rest.substr(0, 1); const bool digit = rest.size() == 1 && rest[0] >= '1' && rest[0] <= '6'; if (!rest.empty() && !digit) break; if (stem == "ANTHROPIC_API_KEY" && !digit) break; @@ -980,26 +996,54 @@ namespace Crafter::Review::Seats { return Dyn::Join(lines, "\n"); } - std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, const Json& board, const std::map& keys) { - std::vector> rows = {{"seat", "state", "driver spend this cycle", "procs", "cycle from", "ledger: out", "in flight"}}; + std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, double now, const Json& board, const std::map& keys) { + const std::string today = Platform::LocalTime("%Y-%m-%d", now); + // "17 Sept" + auto date = [](const std::string& iso) { + return std::format("{} {}", std::stoi(iso.substr(8, 2)), Months[static_cast(std::stoi(iso.substr(5, 2)) - 1)]); + }; + std::vector> rows = {{"seat", "probe", "driver", "driver spend this cycle", "procs", "cycle from", "grant day", "next credits", "in flight"}}; + std::vector notes; for (const Json& s : SortedBySeat(apiSeats)) { + const std::string id = std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))); const Json& ledger = Dyn::Or(Dyn::Get(s, "ledger"), Dyn::EmptyDict()); const double spent = NumberOr0(Dyn::Get(ledger, "spentUsd")); - const std::string state = Dyn::Str(Dyn::At(s, "state")) + (Dyn::Truthy(Dyn::Get(s, "error")) && Dyn::At(s, "state") != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string()); - const Json& outSince = Dyn::Get(ledger, "outSince"); + const std::string state = Dyn::Str(Dyn::At(s, "state")); + const std::string probe = state + (Dyn::Truthy(Dyn::Get(s, "error")) && state != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string()); + // The driver's verdict, SeatPool::LedgerOut's. + const Json& ledgerOut = Dyn::Get(s, "ledgerOut"); + const bool out = !ledgerOut.is_null(); + const std::string driver = !out ? std::string("ok") + : ledgerOut == "credit" ? std::format("out (credit since {})", Dyn::Replace(Dyn::Slice(Dyn::Str(Dyn::Get(ledger, "outSince")), 0, 16), "T", " ")) + : std::string("out (budget spent)"); + const std::string due = Dyn::StrOr(Dyn::Get(s, "nextCredits"), ""); + const std::string dueText = due.size() < 10 ? std::string("—") : due == today ? std::string("today") : due < today ? std::format("overdue, {}", date(due)) : date(due); const std::int64_t n = Inflight(board, keys, s); - rows.push_back({ - std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))), state, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"), - Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::Truthy(outSince) ? std::format("since {}", Dyn::Replace(Dyn::Slice(Dyn::Str(outSince), 0, 16), "T", " ")) : std::string("—"), - n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—"), - }); + rows.push_back({id, probe, driver, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"), + Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::StrOr(Dyn::Get(s, "grantDay"), "—"), dueText, + n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—")}); + const Json& event = Dyn::Get(s, "event"); + if (event == "new-grant") { + notes.push_back(std::format("{}: the key answers again - new credits; the ledger cycle restarted (from {})", id, Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"))); + } else if (event == "credit-gone") { + notes.push_back(std::format("{}: the credit error, now recorded in the ledger", id)); + } else if (state == "ok" && ledgerOut == "credit") { + notes.push_back(std::format("{}: the key answered, but the credit error was recorded since the probe started - the ledger's verdict stands", id)); + } else if (state == "ok" && out) { + notes.push_back(std::format("{}: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)", id)); + } else if (state != "ok" && state != "out" && out) { + notes.push_back(std::format("{}: probe {} - the ledger's verdict stands", id, state)); + } } - return Dyn::Join(std::vector{ - RenderTable(rows), - std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · cycle day {} · {} processes per seat · placed at +${} each", - Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))), - "the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent", - }, "\n"); + std::vector lines = {RenderTable(rows)}; + lines.insert(lines.end(), notes.begin(), notes.end()); + lines.push_back(std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · a cycle starts on the seat's grant day (ANTHROPIC_API_KEY_GRANT_DAY, else " + "REVIEW_GATES_API_CYCLE_DAY={}) · {} processes per seat · placed at +${} each", + Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), + Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd")))); + lines.emplace_back("the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent, and \"overdue\" means the grant day " + "is wrong"); + return Dyn::Join(lines, "\n"); } std::string RenderDrivers(const Json& board, double now, const Zone& zone) { diff --git a/interfaces/Crafter.Review-SeatPool.cppm b/interfaces/Crafter.Review-SeatPool.cppm index 243dab5..7d06a16 100644 --- a/interfaces/Crafter.Review-SeatPool.cppm +++ b/interfaces/Crafter.Review-SeatPool.cppm @@ -133,28 +133,66 @@ export namespace Crafter::Review::SeatPool { // The other drivers' load on a seat as the last board fold left it. Json Foreign(const Json& seat); std::filesystem::path LedgerPath(const std::optional& scratchRoot = std::nullopt); - // The most recent date with that day of the month, as YYYY-MM-DD. + // The most recent date with that day of the month, as YYYY-MM-DD; a day + // past a month's end is its last day. std::string CycleStart(double now, std::int32_t day); + // When a seat's next credits are due, as YYYY-MM-DD: the grant day after + // the current cycle's, or the current cycle's own when the seat went out + // on credit before it (outSince) and is still out - due today, or overdue: + // the grant day is wrong. + std::string NextGrant(double now, std::int32_t day, const Json& outSince = nullptr); + // The driver's verdict on a paid seat from its ledger entry alone: out + // with the reason, or ok with none. + std::pair LedgerVerdict(const Json& entry, double budget); + // Why that verdict holds a seat out: "credit" (the key answered the + // credit error), "budget", or null when it does not. + Json LedgerOut(const Json& entry, double budget); // What the driver spent on each API seat this cycle, and whether the key // answered the credit error; one file for every driver, each write a delta - // applied under its lock. + // applied under its lock. A seat's cycle starts on its grant day (days, + // else the config's cycleDay). class Ledger { public: - Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object()); - static Ledger Load(const std::filesystem::path& path, const Json& apiConfig); + Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object(), std::map days = {}); + static Ledger Load(const std::filesystem::path& path, const Json& apiConfig, std::map days = {}); + std::int32_t Day(const std::string& seatId) const; + void SetDay(const std::string& seatId, std::int32_t day); + // The seat's entry of the current cycle. A new cycle starts the spend + // over; a credit-out carries over it - only a probe that sees the key + // answer again lifts it (Reconcile) - with the time the key was last + // seen answering again. This process's copy: only an Update + // re-reads the file. Json& Entry(const std::string& seatId, std::optional now = std::nullopt); Json Add(const std::string& seatId, double usd, std::optional now = std::nullopt); Json MarkOut(const std::string& seatId, std::string_view reason, std::optional now = std::nullopt); + // The probe's reading of an API key (probedAt: when the probe + // started), and the ledger follows it, decided under the lock on the + // entry as the file has it: "ok" on a key held out on credit since + // before the probe started lifts the mark - an API key's is new + // credits, GrantEntry's entry, "new-grant"; an OpenRouter key's keeps + // its cycle's spend, "credit-back" -; the credit error on a key the file + // has as fine, no grant seen since the probe started, is recorded, + // "credit-gone". Anything else writes nothing, "": a mark another + // driver made after the probe started may postdate its reading, and + // a grant another driver already recorded keeps its spend. + std::string Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional now = std::nullopt); void Save(); const Json& Data() const; const std::filesystem::path& Path() const; + // The config the ledger was loaded with (ApiConfig's). + const Json& ApiConfig() const; private: std::filesystem::path path; Json apiConfig; Json data; - Json Update(const std::string& seatId, const std::function& change, std::optional now); + std::map days; + // change returns whether to write the file. + Json Update(const std::string& seatId, const std::function& change, std::optional now); + // A grant's entry: the cycle from the seat's grant day when that is + // within the week, else from today; no spend, no out mark. + Json GrantEntry(const std::string& seatId, double at) const; }; Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret); @@ -163,12 +201,23 @@ export namespace Crafter::Review::SeatPool { // local), local | api | oauth | openrouter, // or a list such as api1,1,2,local. std::invalid_argument with a // ValueError message for a seat that is not configured, or OpenRouter - // mixed with the rest. - std::vector LoadSeats(const std::map& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr); + // mixed with the rest. An API seat gets its grantDay + // (ANTHROPIC_API_KEY_GRANT_DAY of env, else the cycleDay) into the + // seat and the ledger; a seat the ledger holds out is out, ledgerOut + // "credit" or "budget". + std::vector LoadSeats(const std::map& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr, + std::optional now = std::nullopt); using ProbeFunction = std::function; - // One probe per seat before the run; a seat already out is skipped. + // An OpenRouter key's probe, ProbeOpenRouter's shape. + using OpenRouterProbeFunction = std::function; + // One probe per seat before the run; a seat already out is skipped, + // except a paid key the ledger holds out on credit: probed again + // (Reconcile). A probed key that answers with its cycle's budget spent + // (an OpenRouter key's lift keeps its spend) is out on that budget. The + // OpenRouter price catalogue is fetched when there are models to price. void Probe(std::vector& seats, const std::string& claudeBin, double timeout, const std::vector& models, - const std::function& log, const ProbeFunction& probe = {}); + const std::function& log, const ProbeFunction& probe = {}, Ledger* ledger = nullptr, std::optional now = std::nullopt, + const OpenRouterProbeFunction& probeKey = {}); void ApplyProbe(Json& seat, const Json& result); std::string Describe(const Json& seat); diff --git a/interfaces/Crafter.Review-Seats.cppm b/interfaces/Crafter.Review-Seats.cppm index 8f59529..da98783 100644 --- a/interfaces/Crafter.Review-Seats.cppm +++ b/interfaces/Crafter.Review-Seats.cppm @@ -46,13 +46,18 @@ export namespace Crafter::Review::Seats { Json ConfigFromEnv(const std::map& env); std::vector> SeatTokensFromEnv(const std::map& env); std::vector> ApiKeysFromEnv(const std::map& env); + // {n: day}: ANTHROPIC_API_KEY_GRANT_DAY, the day of the month seat n's + // organization receives its plan's credits (the Console's + // Promotional-credits expiry minus three days), 1..31; anything else is + // ignored. + std::map ApiGrantDays(const std::map& env); std::vector> OpenRouterKeysFromEnv(const std::map& env); // Every `[export ]NAME=value` line of a dotenv text, in order: blanks // around the `=` and the value dropped, one pair of matching quotes // stripped; anything else skipped. std::vector> DotenvLines(std::string_view text); // Only the seat lines of those: the OAuth tokens, the numbered API keys - // and the OpenRouter keys; everything else ignored. + // with their grant days and the OpenRouter keys; everything else ignored. std::map SeatTokensFromEnvFile(std::string_view text); // The REVIEW_GATES_* lines of a dotenv file (quotes and `export` // stripped). SeatPool::SettingsOf lays the placement settings among them @@ -142,7 +147,11 @@ export namespace Crafter::Review::Seats { std::string RenderTable(const std::vector>& rows); std::string RenderStatusReport(const std::vector& seats, const Json& config, double now, const Zone& zone, const Json& board = nullptr, const std::map& keys = {}); - std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, const Json& board = nullptr, + // API-credit seats: the probe's verdict beside the driver's (its ledger + // against the budget), the cycle, the grant day and when the next + // credits are due - each seat's ledger, ledgerOut, grantDay, nextCredits + // and the probe's ledger event as the seats command set them. + std::string RenderApiReport(const std::vector& apiSeats, const Json& apiConfig, double now, const Json& board = nullptr, const std::map& keys = {}); std::string RenderDrivers(const Json& board, double now, const Zone& zone); } diff --git a/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml b/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml index 7d66405..d423f8a 100644 --- a/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml +++ b/packaging/flatpak/net.catcrafts.CrafterReview.metainfo.xml @@ -31,8 +31,13 @@

New

    +
  • Each API seat's credit cycle starts on its own grant day, ANTHROPIC_API_KEY<n>_GRANT_DAY in the seats file. A seat out of credit stays out across the rollover until a probe sees its key answer again, and seats shows each API seat's grant day, when its next credits are due and whether the driver holds it out.
  • Leaving authors out of the config turns the freeze's author and committer check off, where it used to check git config user.email. An empty list is a config error.
+

Fixes

+
    +
  • An OpenRouter key that ran out of credit is probed again at every run instead of waiting for the month's turn.
  • +
diff --git a/tests/Commands/main.cpp b/tests/Commands/main.cpp index c416bb8..b3577a1 100644 --- a/tests/Commands/main.cpp +++ b/tests/Commands/main.cpp @@ -37,6 +37,12 @@ model= prev= for a in "$@"; do [ "$prev" = --model ] && model=$a; prev=$a; done cat > /dev/null +# A Console key whose credit is gone, answering after two seconds. +if [ "$ANTHROPIC_API_KEY" = sk-ant-api-spent ]; then + sleep 2 + echo '{"type":"result","subtype":"success","is_error":true,"result":"Credit balance is too low","total_cost_usd":0}' + exit 1 +fi now=$(date +%s) fable= [ "$model" = claude-fable-5-1 ] && fable=",\"seven_day_overage_included\":{\"utilization\":0.3,\"resetsAt\":$((now+172800))}" @@ -340,7 +346,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total // --env (a prefix of --env-file) names the file for the tokens and // the settings alike, with ./.env in the way (the API seat's section // is where the order shows). - Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nREVIEW_GATES_SEAT_ORDER=oauth-first\n"); + Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=16\nREVIEW_GATES_SEAT_ORDER=oauth-first\n"); const Process::Captured other = Self({"seats", "--no-fable", "--env", "other.env"}, base / "seats", withFake); // An application's .env (no seat line, no setting) is passed over for // the machine's own seats file. @@ -365,8 +371,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board; an application's .env " "is passed over for the machine's own file; a FIFO is read once, its settings and its seats alike", report.returnCode == 0 && report.out.starts_with("seat pool: 3 seat(s) (local, 1, api1) from .env and your own login, probed ") - && report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running") - && other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | $0.00 of $200") + && report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | ok | $0.00 of $200") && report.out.contains("drivers: none running") + && other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | ok | $0.00 of $200") && other.out.contains("| 16 |") && other.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)") && !other.out.contains("tok-two") && !other.out.contains("sk-ant-api-two") && appSeats.returnCode == 0 && appSeats.out.starts_with(std::format("seat pool: 1 seat(s) (3) from {}, probed ", (base / "app-xdg" / "crafter-review" / "env").string())) && fromFifo.returnCode == 0 && fromFifo.out.starts_with("seat pool: 2 seat(s) (4, api4) from seats.fifo, probed ") @@ -377,6 +383,29 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total && parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z") && !(report.out + json.out).contains("tok-one") && !(report.out + json.out).contains("sk-ant-api-one"), json.out.substr(0, 600) + json.err); + // The credit error a probe reads is recorded in the ledger stamped + // when it is written, not at the start the probes share (1.1.0 round + // 3): the spent key answers two seconds after the command began. + Write(base / "spent" / ".env", "ANTHROPIC_API_KEY3=sk-ant-api-spent\n"); + const double spentStart = Platform::Now(); + const Process::Captured spent = Self({"seats", "--json"}, base / "spent", withFake); + Json spentParsed; + try { + spentParsed = Json::parse(spent.out); + } catch (const std::exception&) { + } + // The child's TZ is UTC (Env): the stamp is compared in UTC. + const auto utc = [](double epoch) { + return std::format("{:%Y-%m-%dT%H:%M:%S}", std::chrono::floor(std::chrono::sys_seconds(std::chrono::seconds(static_cast(epoch))))); + }; + Json spentEntry; + if (spentParsed.is_object() && spentParsed.contains("apiSeats") && !spentParsed.at("apiSeats").empty()) { + spentEntry = Dyn::Get(Dyn::Get(spentParsed.at("apiSeats").at(0), "ledger"), "outSince"); + } + Check("seats: a credit error the probe read is recorded in the shared ledger stamped when written, after the probe, never at the probes' common start", + spent.returnCode == 0 && spentParsed.is_object() && spentParsed.at("apiSeats").at(0).at("event") == "credit-gone" && spentEntry.is_string() + && Dyn::Slice(spentEntry.get(), 0, 19) >= utc(spentStart + 2), + spent.out.substr(0, 800) + spent.err); const Process::Captured none = Self({"seats"}, base / "plain", withFake); const Process::Captured noFile = Self({"seats", "--env-file", "missing.env"}, base / "plain", withFake); Write(base / "plain" / "bad.env", std::string("\xff\n", 2)); diff --git a/tests/SeatPool/main.cpp b/tests/SeatPool/main.cpp index 83f1d28..07e5c69 100644 --- a/tests/SeatPool/main.cpp +++ b/tests/SeatPool/main.cpp @@ -187,6 +187,251 @@ int main() { if (SeatPool::CycleStart(at, day) != want) missed.push_back(std::format("{} {} -> {}", at, day, SeatPool::CycleStart(at, day))); } Check("cycle start: this month's billing day once reached, else last month's, clamped to its length", missed.empty(), Dyn::Join(missed, "; ")); + // 2026-10-17 (the Python driver's, ported 2026-11-02): the cycle is the + // seat's grant day - the credits' own, from the Console's expiry minus + // three days - and a key that answered the credit error stays out across + // the rollover until a probe sees it answer again (then: new credits, a + // new cycle). The Python driver's vectors. + { + auto at = [](std::int32_t y, unsigned m, unsigned d) { + using namespace std::chrono; + return static_cast(duration_cast((sys_days(year_month_day(year(y), month(m), day(d))) + hours(12)).time_since_epoch()).count()); + }; + Check("cycle start: a day past the month's end is its last day", + SeatPool::CycleStart(at(2026, 4, 30), 31) == "2026-04-30" && SeatPool::CycleStart(at(2026, 3, 1), 31) == "2026-02-28" && SeatPool::CycleStart(at(2026, 3, 30), 28) == "2026-03-28"); + constexpr double t17 = at(2026, 10, 17); + Check("next credits: the grant day after the cycle's; the cycle's own when the seat went out on credit before it", + SeatPool::NextGrant(t17, 17) == "2026-11-17" && SeatPool::NextGrant(t17, 5) == "2026-11-05" && SeatPool::NextGrant(t17, 17, "2026-10-08T20:19:06") == "2026-10-17" + && SeatPool::NextGrant(t17, 5, "2026-10-13T22:46:02") == "2026-11-05" && SeatPool::NextGrant(at(2026, 12, 20), 31) == "2026-12-31"); + Check("ledger verdict: a credit-out, the budget, else ok", + SeatPool::LedgerVerdict({{"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}, {"spentUsd", 0}}, 200) + == std::pair(true, "ledger: credit balance too low since 2026-10-08T20:19") + && SeatPool::LedgerVerdict({{"spentUsd", 200.0}}, 200) == std::pair(true, "ledger: $200.00 spent this cycle, budget $200") + && SeatPool::LedgerVerdict({{"spentUsd", 1}}, 200) == std::pair(false, "")); + const std::string text2 = "ANTHROPIC_API_KEY3=sk-x\nANTHROPIC_API_KEY3_GRANT_DAY=16\nANTHROPIC_API_KEY4_GRANT_DAY = 5\nANTHROPIC_API_KEY1_GRANT_DAY=32\nANTHROPIC_API_KEY2_GRANT_DAY=x\nANTHROPIC_API_KEY_GRANT_DAY=9\n"; + Check("a grant day: decimal digits of any script, leading zeros of any length; anything else ignored", + Seats::ApiGrantDays({{"ANTHROPIC_API_KEY1_GRANT_DAY", "0000000016"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "\uFF11\uFF16"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "1_6"}, + {"ANTHROPIC_API_KEY4_GRANT_DAY", "99999999999999999999"}, {"ANTHROPIC_API_KEY5_GRANT_DAY", "\u00B2"}, {"ANTHROPIC_API_KEY6_GRANT_DAY", "0"}}) + == std::map {{1, 16}, {2, 16}}); + const std::map tokens2 = Seats::SeatTokensFromEnvFile(text2); + Check("the grant day per seat from the dotenv file, 1..31 only, never a key", + tokens2 == std::map {{"ANTHROPIC_API_KEY3", "sk-x"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "16"}, {"ANTHROPIC_API_KEY4_GRANT_DAY", "5"}, + {"ANTHROPIC_API_KEY1_GRANT_DAY", "32"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "x"}} + && Seats::ApiGrantDays(tokens2) == std::map {{3, 16}, {4, 5}} + && Seats::ApiKeysFromEnv(tokens2) == std::vector> {{3, "sk-x"}}); + SeatPool::Ledger old(td / "old.json", acfg, + {{"api1", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}}, + {"api2", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", nullptr}, {"outReason", nullptr}}}, + {"or1", {{"cycle", "1999-01-01"}, {"spentUsd", 50}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}}}); + Check("a new cycle resets the spend; a credit-out carries over until a probe lifts it, an OpenRouter key's too (the month's turn puts nothing on it)", + old.Entry("api1").at("spentUsd") == 0 && old.Entry("api1").at("procs") == 0 && old.Entry("api1").at("outSince") == "x" && old.Entry("api2").at("outSince").is_null() + && old.Entry("api2").at("spentUsd") == 0 && old.Entry("or1").at("outSince") == "x" && old.Entry("or1").at("outReason") == "y" && old.Entry("or1").at("spentUsd") == 0); + Check("ledger out: credit, budget, else null", + SeatPool::LedgerOut({{"outSince", "2026-10-08T20:19:06"}, {"spentUsd", 0}}, 200) == "credit" && SeatPool::LedgerOut({{"spentUsd", 200.0}}, 200) == "budget" + && SeatPool::LedgerOut({{"spentUsd", 1}}, 200).is_null()); + const fs::path daysEnv = td / "days.env"; + Write(daysEnv, "ANTHROPIC_API_KEY1=sk-ant-api-one\nANTHROPIC_API_KEY1_GRANT_DAY=17\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=0\n"); + SeatPool::Ledger lg(td / "days.json", acfg, + {{"api1", {{"cycle", "2026-10-01"}, {"spentUsd", 200.18}, {"procs", 112}, {"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}}}, + {"api2", {{"cycle", "2026-09-01"}, {"spentUsd", 150.0}, {"procs", 90}, {"outSince", nullptr}, {"outReason", nullptr}}}}); + std::vector ds = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api", &lg, acfg, t17); + Check("the grant day per seat from the seats file (0 is ignored: the global day), into the ledger", + Ids(ds) == "api1,api2" && ds[0].at("grantDay") == 17 && ds[1].at("grantDay") == 1 && lg.Day("api1") == 17 && lg.Day("api2") == 1, Ids(ds)); + Check("rollover on the grant day: the spend starts over, a credit-out carries - the seat stays out", + ds[0].at("state") == "out" && ds[0].at("ledgerOut") == "credit" && ds[0].at("spentUsd") == 0 && lg.Entry("api1", t17).at("cycle") == "2026-10-17" + && lg.Entry("api1", t17).at("procs") == 0 && ds[0].at("error").get().contains("since 2026-10-08"), + DumpSpaced(ds[0])); + Check("...a budget-out resets with its cycle", + ds[1].at("state") == "unknown" && Dyn::Get(ds[1], "ledgerOut").is_null() && ds[1].at("spentUsd") == 0 && lg.Entry("api2", t17).at("cycle") == "2026-10-01"); + std::vector calls; + std::map answers = {{"api1", "ok"}, {"api2", "out"}}; + const SeatPool::ProbeFunction fake = [&](std::int32_t n, const std::string&, const std::string& model, double, const std::string&, bool, bool) { + const std::string id = std::format("api{}", n); + calls.push_back(id); + const std::string state = answers.at(id); + return Json {{"seat", n}, {"state", state}, {"windows", Json::object()}, {"probedWith", model}, + {"error", state == "ok" ? Json() : Json(state == "out" ? "Your credit balance is too low" : "probe timeout after 1000 ms")}}; + }; + std::vector plog; + auto plogf = [&](const std::string& line) { plog.push_back(line); }; + SeatPool::Probe(ds, "claude", 1, {"claude-opus-5-5"}, plogf, fake, &lg, t17); + const Json e1 = lg.Entry("api1", t17); + Check("the run-start probe re-probes a credit-out api seat; a clean answer is new credits: the seat is back, its cycle restarted on its grant day", + calls == std::vector {"api1", "api2"} && ds[0].at("state") == "ok" && ds[0].at("ledgerOut").is_null() && ds[0].at("error").is_null() && e1.at("outSince").is_null() + && e1.at("cycle") == "2026-10-17" && e1.at("grantSeen") == "2026-10-17T12:00:00+00:00" + && std::ranges::any_of(plog, [](const std::string& line) { return line.contains("new credits"); }), + Dyn::Join(plog, " | ")); + const Json e2 = lg.Entry("api2", t17); + Check("...the credit error on a seat the ledger had as fine is recorded, with the next credits in the reason", + ds[1].at("state") == "out" && ds[1].at("ledgerOut") == "credit" && Dyn::Truthy(e2.at("outSince")) + && e2.at("outSince") == "2026-10-17T12:00:00+00:00" && ds[1].at("error") == "credit balance too low since 2026-10-17T12:00; next credits due 2026-11-01", + Dyn::Str(ds[1].at("error"))); + Check("reconcile: nothing to do when the probe and the ledger agree", lg.Reconcile("api1", "ok", t17, t17).empty() && lg.Reconcile("api2", "out", t17, t17).empty()); + // Two drivers on one ledger (1.1.0 round 2): the decision is made on + // the file's entry under the lock, never on a copy read before the + // probe, and a mark made since the probe started stands. + const std::string outLine = R"("cycle": "2026-10-17", "spentUsd": 0, "procs": 0, "outSince": "2026-10-08T20:19:06", "outReason": "credit balance too low")"; + Write(td / "race1.json", std::format("{{\"api1\": {{{}}}}}", outLine)); + SeatPool::Ledger la = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}); + SeatPool::Ledger lb = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}); + const std::string firstGrant = la.Reconcile("api1", "ok", t17, t17); + la.Add("api1", 5.0, t17); + const std::string secondGrant = lb.Reconcile("api1", "ok", t17 + 60, t17 + 60); + const Json race1 = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60); + Check("two drivers: the second's copy, read before the first saw the grant, lifts nothing - the first's grant and its spend stand", + firstGrant == "new-grant" && secondGrant.empty() && race1.at("spentUsd") == 5.0 && race1.at("procs") == 1 && race1.at("outSince").is_null(), DumpSpaced(race1)); + Write(td / "race2.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 199.4, "procs": 180, "outSince": "2026-10-17T12:00:06", "outReason": "credit balance too low"}})"); + SeatPool::Ledger lc = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}}); + const std::string late = lc.Reconcile("api1", "ok", t17, t17 + 30); + const Json race2 = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 30); + Check("a credit error recorded after the probe started outlives its answer: no grant, the spend kept", + late.empty() && race2.at("outSince") == "2026-10-17T12:00:06" && race2.at("spentUsd") == 199.4 && race2.at("procs") == 180, DumpSpaced(race2)); + Write(td / "race3.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-10-17T12:00:10"}})"); + SeatPool::Ledger ld = SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}}); + const std::string stale = ld.Reconcile("api1", "out", t17, t17 + 30); + const std::string fresh = ld.Reconcile("api1", "out", t17 + 60, t17 + 60); + Check("...and a credit error read before a grant another driver saw since records nothing; one read after it does", + stale.empty() && fresh == "credit-gone" && SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60).at("spentUsd") == 2); + // The grant seen at 23:59:55, the cycle turning at midnight (grant + // day 17) before a credit error read at 23:59:40 is reconciled. + Write(td / "race6.json", R"({"api1": {"cycle": "2026-11-16", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-11-16T23:59:55"}})"); + SeatPool::Ledger le = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}}); + const std::string acrossMidnight = le.Reconcile("api1", "out", at(2026, 11, 16) + 43180, at(2026, 11, 17) - 43198); + const Json race6 = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}}).Entry("api1", at(2026, 11, 17) - 43198); + // The stamps carry their UTC offset and Reconcile orders marks by + // instant (1.1.0 round 4). A POSIX rule, not a zone file: Central + // European time, whose summer time ends 2026-10-25 03:00, 02:00-03:00 + // then passing twice. + setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1); + tzset(); + constexpr double fallBack = at(2026, 10, 25) - 39610; + Write(td / "race7.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 150, "procs": 120, "outSince": null, "outReason": null}})"); + SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", fallBack + 15); + const std::string afterFallBack = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", fallBack, fallBack + 16); + const Json race7 = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Entry("api1", fallBack + 16); + // A stamp without an offset, as older drivers wrote, is local time: + // 13:30 CEST is 11:30 UTC, before a probe started at 12:00 UTC (14:00 + // CEST) - new credits; read as UTC it would be after the probe. + Write(td / "race9.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 3, "procs": 1, "outSince": "2026-10-17T13:30:00", "outReason": "credit balance too low"}})"); + const std::string legacy = SeatPool::Ledger::Load(td / "race9.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30); + setenv("TZ", "UTC", 1); + tzset(); + Check("a credit error recorded after the probe started outlives its answer in the hour DST repeats: 02:00:05 CET is after 02:59:50 CEST", + afterFallBack.empty() && race7.at("outSince") == "2026-10-25T02:00:05+01:00" && race7.at("spentUsd") == 150, DumpSpaced(race7)); + Write(td / "race8.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": "2026-10-17T11:00:06-01:00", "outReason": "credit balance too low"}})"); + const std::string otherZone = SeatPool::Ledger::Load(td / "race8.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30); + Check("...and from a driver in another zone: its 11:00:06-01:00 is after a probe started at 12:00:00 UTC", otherZone.empty()); + Check("a stamp without an offset, as older drivers wrote, is read as local time: 13:30 CEST is before a probe started at 14:00 CEST", legacy == "new-grant", legacy); + Write(td / "race10.json", std::format("{{\"api1\": {{{}}}}}", outLine)); + const std::string race10Before = Platform::ReadFile(td / "race10.json").value_or(""); + const std::string unread = SeatPool::Ledger::Load(td / "race10.json", acfg, {{"api1", 17}}).Reconcile("api1", "unknown", t17, t17); + Check("a reading neither ok nor the credit error writes nothing", unread.empty() && Platform::ReadFile(td / "race10.json").value_or("") == race10Before); + // OpenRouter keys (1.1.0, Jorijn: a prepaid key has no cycle of its + // own): one the ledger holds out on credit is probed again through + // the key probe, and room on its limit lifts the mark with its + // cycle's spend kept; the limit reached is recorded; one out on its + // budget is not probed. Their cycle is the cycle day's. + Write(td / "or.env", "OPENROUTER_KEY1=or-one\nOPENROUTER_KEY2=or-two\nOPENROUTER_KEY3=or-three\n"); + Write(td / "or-ledger.json", R"({"or1": {"cycle": "2026-10-01", "spentUsd": 7.5, "procs": 3, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"}, + "or2": {"cycle": "2026-10-01", "spentUsd": 1, "procs": 1, "outSince": null, "outReason": null}, + "or3": {"cycle": "2026-10-01", "spentUsd": 150, "procs": 90, "outSince": null, "outReason": null}})"); + SeatPool::Ledger orLedger = SeatPool::Ledger::Load(td / "or-ledger.json", acfg); + std::vector orSeats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or.env").string(), td)), "openrouter", &orLedger, acfg, t17); + std::vector keyCalls; + const SeatPool::OpenRouterProbeFunction fakeKey = [&](const std::string& secret, double) { + keyCalls.push_back(secret); + return secret == "or-one" ? Json {{"state", "ok"}, {"remaining", 3.0}} : Json {{"state", "out"}, {"error", "key limit reached ($10.00 of $10 used)"}, {"remaining", 0}}; + }; + plog.clear(); + SeatPool::Probe(orSeats, "claude", 1, {}, plogf, fake, &orLedger, t17, fakeKey); + SeatPool::Ledger orFile = SeatPool::Ledger::Load(td / "or-ledger.json", acfg); + const Json or1 = orFile.Entry("or1", t17); + const Json or2 = orFile.Entry("or2", t17); + Check("an OpenRouter key held out on credit is probed again, one out on its budget is not; room on its limit lifts the mark and keeps its cycle's spend", + Ids(orSeats) == "or1,or2,or3" && keyCalls == std::vector {"or-one", "or-two"} && orSeats[0].at("state") == "ok" && orSeats[0].at("ledgerOut").is_null() + && orSeats[0].at("spentUsd") == 7.5 && or1.at("outSince").is_null() && or1.at("spentUsd") == 7.5 && or1.at("procs") == 3 && or1.at("cycle") == "2026-10-01" + && or1.at("grantSeen") == "2026-10-17T12:00:00+00:00" && std::ranges::any_of(plog, [](const std::string& line) { return line.contains("limit has room again"); }) + && orSeats[2].at("state") == "out" && orSeats[2].at("ledgerOut") == "budget", + std::format("{} {}", DumpSpaced(or1), Dyn::Join(keyCalls, ","))); + Check("...and the limit reached is recorded as the key's credit-out, the probe's error the seat's reason", + orSeats[1].at("state") == "out" && orSeats[1].at("ledgerOut") == "credit" && orSeats[1].at("error") == "key limit reached ($10.00 of $10 used)" + && or2.at("outSince") == "2026-10-17T12:00:00+00:00" && or2.at("outReason") == "key limit reached", + DumpSpaced(or2)); + // A key that answers with its cycle's budget spent is out on that + // budget, not left ok - neither placed nor out, a pool of it waiting + // forever (1.1.0 round 9): an OpenRouter key held out on credit with + // its spend over the budget, and an API key another driver lifted and + // spent the budget on since this copy was read. + Write(td / "or-spent.env", "OPENROUTER_KEY1=or-one\n"); + Write(td / "or-spent.json", + R"({"or1": {"cycle": "2026-10-01", "spentUsd": 100.4, "procs": 40, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"}})"); + SeatPool::Ledger spentLedger = SeatPool::Ledger::Load(td / "or-spent.json", acfg); + std::vector spentSeats = + SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or-spent.env").string(), td)), "openrouter", &spentLedger, acfg, t17); + SeatPool::Probe(spentSeats, "claude", 1, {}, plogf, fake, &spentLedger, t17, fakeKey); + Check("an OpenRouter key held out on credit with its budget spent too: the mark lifted, the seat out on the budget, a pool of it all out", + spentSeats[0].at("state") == "out" && spentSeats[0].at("ledgerOut") == "budget" && SeatPool::AllOut(spentSeats) + && SeatPool::Ledger::Load(td / "or-spent.json", acfg).Entry("or1", t17).at("outSince").is_null(), + DumpSpaced(spentSeats[0])); + Write(td / "race11.json", std::format("{{\"api1\": {{{}}}}}", outLine)); + SeatPool::Ledger stale11(SeatPool::Ledger::Load(td / "race11.json", acfg)); + std::vector mine11 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale11, acfg, t17); + SeatPool::Ledger other11 = SeatPool::Ledger::Load(td / "race11.json", acfg, {{"api1", 17}}); + other11.Reconcile("api1", "ok", t17, t17); + other11.Add("api1", 12.0, t17); + answers["api1"] = "ok"; + SeatPool::Probe(mine11, "claude", 1, {}, plogf, fake, &stale11, t17 + 60); + Check("...and an API key another driver lifted and spent its budget on since: out on the budget", + mine11[0].at("state") == "out" && mine11[0].at("ledgerOut") == "budget" && mine11[0].at("spentUsd") == 12.0 && SeatPool::AllOut(mine11), DumpSpaced(mine11[0])); + Check("an OpenRouter key's cycle starts on the cycle day", orLedger.Day("or1") == 1 && SeatPool::Ledger::Load(td / "or-ledger.json", acfg).Entry("or1", at(2026, 11, 2)).at("cycle") == "2026-11-01"); + Check("...across the cycle's turn too: the grant's time carries over the rollover with an API seat's entry", + acrossMidnight.empty() && race6.at("outSince").is_null() && race6.at("grantSeen") == "2026-11-16T23:59:55" && race6.at("cycle") == "2026-11-17", DumpSpaced(race6)); + Write(td / "race4.json", std::format("{{\"api1\": {{{}}}}}", outLine)); + SeatPool::Ledger stale4(SeatPool::Ledger::Load(td / "race4.json", acfg)); + std::vector mine = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale4, acfg, t17); + SeatPool::Ledger other4 = SeatPool::Ledger::Load(td / "race4.json", acfg, {{"api1", 17}}); + other4.Reconcile("api1", "ok", t17, t17); + other4.Add("api1", 5.0, t17); + answers["api1"] = "ok"; + plog.clear(); + SeatPool::Probe(mine, "claude", 1, {}, plogf, fake, &stale4, t17 + 60); + Check("the run's probe after another driver's grant: the seat is back with that driver's spend, no second grant", + mine[0].at("state") == "ok" && mine[0].at("spentUsd") == 5.0 && mine[0].at("ledgerOut").is_null() + && std::ranges::none_of(plog, [](const std::string& line) { return line.contains("new credits"); }) + && SeatPool::Ledger::Load(td / "race4.json", acfg).Entry("api1", t17 + 60).at("spentUsd") == 5.0, + DumpSpaced(mine[0])); + Write(td / "race5.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null}})"); + SeatPool::Ledger mine5(SeatPool::Ledger::Load(td / "race5.json", acfg)); + std::vector seats5 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &mine5, acfg, t17); + SeatPool::Ledger::Load(td / "race5.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", t17 + 6); + SeatPool::Probe(seats5, "claude", 1, {}, plogf, fake, &mine5, t17); + Check("...and the run's probe that answered before another driver's credit error: the seat is out on the ledger's verdict", + seats5[0].at("state") == "out" && seats5[0].at("ledgerOut") == "credit" && seats5[0].at("error") == "ledger: credit balance too low since 2026-10-17T12:00", + DumpSpaced(seats5[0])); + calls.clear(); + answers["api1"] = "unknown"; + Json apiB = With(ds[1], {{"state", "out"}, {"ledgerOut", "budget"}, {"error", "ledger: $10.00 spent this cycle, budget $10"}}); + Json apiU = With(ds[0], {{"state", "out"}, {"ledgerOut", "credit"}, {"error", "ledger: credit balance too low since 2026-10-08T20:19"}}); + std::vector pair2 = {apiB, apiU}; + SeatPool::Probe(pair2, "claude", 1, {}, plogf, fake, nullptr, t17); + Check("a budget-out seat is not probed; a credit-out seat whose probe is unreadable stays out on the ledger's verdict", + calls == std::vector {"api1"} && pair2[0].at("state") == "out" && pair2[1].at("state") == "out" + && pair2[1].at("error") == "ledger: credit balance too low since 2026-10-08T20:19 (probe unreadable: probe timeout after 1000 ms)", + Dyn::Str(pair2[1].at("error"))); + // A grant's cycle, through the production path: the credit error + // recorded, then a probe that started after it answers. + lg.SetDay("api3", 5); + lg.MarkOut("api3", "credit balance too low", t17 - 60); + const std::string grant3 = lg.Reconcile("api3", "ok", t17, t17); + Check("new credits seen 12 days after the configured day: the cycle starts today, not on that day", + grant3 == "new-grant" && lg.Entry("api3", t17).at("cycle") == "2026-10-17" && lg.Entry("api3", t17).at("spentUsd") == 0); + lg.SetDay("api4", 16); + lg.MarkOut("api4", "credit balance too low", t17 - 60); + const std::string grant4 = lg.Reconcile("api4", "ok", t17, t17); + Check("...and on the day when it is within the week", grant4 == "new-grant" && lg.Entry("api4", t17).at("cycle") == "2026-10-16" + && lg.Entry("api4", at(2026, 11, 15)).at("cycle") == "2026-10-16" + && lg.Entry("api4", at(2026, 11, 16)).at("cycle") == "2026-11-16"); + } const std::string knobs = DumpSpaced(SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", " 1_0 "}, {"REVIEW_GATES_API_CONCURRENCY", "3.9"}, {"REVIEW_GATES_API_CYCLE_DAY", "40"}, @@ -194,7 +439,7 @@ int main() { {"REVIEW_GATES_OPENROUTER_BUDGET_USD", "-1"}, {"REVIEW_GATES_API_LENS_COST_USD", "nan"}})); Check("api config: float() parsing, int() truncation, the cycle day clamped, a negative or NaN value the default", - knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 28, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs); + knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 31, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs); const double cost = SeatPool::OpenRouterCost("openrouter/xiaomi/mimo-v2.6-pro", {{"input", 1234}, {"cacheCreation", 10}, {"cacheRead", 99999}, {"output", 777}}); Check("openrouter cost: tokens x the built-in list prices, an unknown model free", cost == 0.001577 && SeatPool::OpenRouterCost("nope", {{"input", 1}}) == 0.0, Dyn::FloatRepr(cost)); Json described = SeatPool::NewSeat("1", "oauth", "t"); @@ -222,7 +467,7 @@ int main() { lines == std::vector> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}, {"CLAUDE_CODE_OAUTH_TOKEN1", "x"}, {"REVIEW_GATES", "nope"}} && settings == std::map {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}} && Seats::SeatTokensFromEnvFile("ANTHROPIC_API_KEY=bare\nANTHROPIC_API_KEY2=k\nANTHROPIC_API_KEY2_GRANT_DAY=17\nCLAUDE_CODE_OAUTH_TOKEN=t\nCLAUDE_CODE_OAUTH_TOKEN7=no\n") - == std::map {{"ANTHROPIC_API_KEY2", "k"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}}); + == std::map {{"ANTHROPIC_API_KEY2", "k"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "17"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}}); const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid()); fs::remove_all(envTd); fs::create_directories(envTd / "repo"); @@ -248,7 +493,7 @@ int main() { && !applied.env.contains("REVIEW_GATES_CLAUDE_BIN") && !applied.env.contains("REVIEW_GATES_SEAT_POLICY") && api.at("order") == "oauth-first" && api.at("budgetUsd") == 75.0 && !Platform::GetEnv("REVIEW_GATES_SEAT_ORDER") && !Platform::GetEnv("REVIEW_GATES_CLAUDE_BIN") && Platform::GetEnv("REVIEW_GATES_API_BUDGET_USD") == "75" && SeatPool::SettingsOf(SeatPool::ResolveSeatsFile(std::string(), envTd / "repo")).fromFile.empty() && SeatPool::SettingsOf(missing).fromFile.empty() && raised == "FileNotFoundError", - Dyn::Str(api) + " " + raised); + std::format("{} {}", Dyn::Str(api), raised)); // Every name on the list moves a placement reader (the list is the // readers' names, not a guess at them). const Json apiDefault = SeatPool::ApiConfig({}); diff --git a/tests/Seats/main.cpp b/tests/Seats/main.cpp index a49c68c..6ed6a09 100644 --- a/tests/Seats/main.cpp +++ b/tests/Seats/main.cpp @@ -4,6 +4,8 @@ // Seat usage: the pure functions against reference vectors, numbered in // order, so "check 17" always names the same vector. +#include +#include import std; import Crafter.Review; namespace fs = std::filesystem; @@ -45,6 +47,10 @@ namespace { } int main() { + // The API report's dates (NextGrant, its "today") are the process zone's, + // as for dt.date.fromtimestamp: the vectors below are UTC's. + setenv("TZ", "UTC", 1); + tzset(); const Zone utc = ZoneNamed("UTC"); const Zone ams = ZoneNamed("Europe/Amsterdam"); // 2026-09-07 10:00 UTC @@ -232,6 +238,44 @@ int main() { Eq(ClipReason("token sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123 and jorijn@example.com x\n y", secret), "token [redacted:token] and [redacted:email] x y"); Eq(ClipReason("bad lit-secret here", secret), "bad [redacted:token] here"); Eq(LooksLikeAuthFailure({{"api_error_status", 401}}, ""), true); + // The API report (2026-10-17, ported 2026-11-02): the probe beside the + // driver's verdict, the cycle, the grant day and the next credits (today, + // a date, overdue) - the Python driver's vectors. + { + const Json acfg = SeatPool::ApiConfig({}); + auto api = [&](std::int32_t n, const std::string& state, const Json& error, std::int32_t day, const Json& ledger, const Json& event = nullptr) { + Json s = {{"seat", n}, {"state", state}, {"error", error}, {"grantDay", day}, {"ledger", ledger}, {"ledgerOut", SeatPool::LedgerOut(ledger, acfg.at("budgetUsd").get())}, + {"nextCredits", SeatPool::NextGrant(now, day, Dyn::Get(ledger, "outSince"))}}; + if (!event.is_null()) s["event"] = event; + return s; + }; + auto entry = [](const std::string& cycle, double spent, std::int32_t procs, const Json& outSince) { + return Json {{"cycle", cycle}, {"spentUsd", spent}, {"procs", procs}, {"outSince", outSince}, {"outReason", outSince.is_null() ? Json() : Json("credit balance too low")}}; + }; + const std::string report = RenderApiReport({api(1, "out", "Your credit balance is too low", 7, entry("2026-09-07", 0.0, 0, "2026-08-20T20:19:06")), + api(3, "ok", nullptr, 7, entry("2026-09-07", 0.0, 0, nullptr), "new-grant"), api(4, "ok", nullptr, 5, entry("2026-09-05", 200.0, 100, nullptr)), + api(2, "unknown", "probe timeout after 30000 ms", 17, entry("2026-08-17", 12.5, 9, "2026-09-01T10:00:00")), + api(5, "out", "Your credit balance is too low", 3, entry("2026-09-03", 0.0, 0, "2026-08-10T10:00:00"))}, + acfg, now); + std::string flat; + for (const char c : report) { + if (c != ' ' || !flat.ends_with(' ')) flat += c; + } + Eq(flat.contains("| seat | probe | driver | driver spend this cycle | procs | cycle from | grant day | next credits |"), true); + Eq(flat.contains("| api1 | out (Your credit balance is too low) | out (credit since 2026-08-20 20:19) | $0.00 of $200 | 0 | 2026-09-07 | 7 | today |"), true); + Eq(flat.contains("| api2 | unknown (probe timeout after 30000 ms) | out (credit since 2026-09-01 10:00) | $12.50 of $200 | 9 | 2026-08-17 | 17 | 17 Sept |"), true); + Eq(flat.contains("| api3 | ok | ok | $0.00 of $200 | 0 | 2026-09-07 | 7 | 7 Oct |"), true); + Eq(flat.contains("| api4 | ok | out (budget spent) | $200.00 of $200 | 100 | 2026-09-05 | 5 | 5 Oct |"), true); + Eq(flat.contains("| api5 | out (Your credit balance is too low) | out (credit since 2026-08-10 10:00) | $0.00 of $200 | 0 | 2026-09-03 | 3 | overdue, 3 Sept |"), true); + std::vector notes; + for (const std::string& line : Lines(flat)) { + if (line.starts_with("api") && notes.size() < 4) notes.push_back(line); + } + EqJson(Json(notes), Json::array({"api2: probe unknown - the ledger's verdict stands", "api3: the key answers again - new credits; the ledger cycle restarted (from 2026-09-07)", + "api4: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)", + "api seats run first (REVIEW_GATES_SEAT_ORDER=api-first) · budget $200 per seat per cycle · a cycle starts on the seat's grant day " + "(ANTHROPIC_API_KEY_GRANT_DAY, else REVIEW_GATES_API_CYCLE_DAY=1) · 8 processes per seat · placed at +$1 each"})); + } Eq(LooksLikeAuthFailure(nullptr, "Invalid API key · Please run /login"), true); Eq(LooksLikeAuthFailure({{"result", "ok"}}, ""), false); // find_claude_bin: CLAUDE_BIN, then PATH, then the newest editor-bundled